Bank account
This section aims to show the real power of the Julia type system in combination with multiple-dispatch. We will present it through an example, where the goal is to create a structure that represents a bank account with the following properties:
- The structure has two fields:
owner
andtransaction
. - It is possible to make transactions in different currencies.
- All transactions are stored in the currency in which they were made.
Before creating such a structure, we first define an abstract type Currency
and its two concrete subtypes.
abstract type Currency end
struct Euro <: Currency
value::Float64
end
struct Dollar <: Currency
value::Float64
end
Since Euro
and Dollar
are concrete types, we can create their instances and use isa
to check that these instances are subtypes of Currency
.
julia> Euro(1)
Euro(1.0)
julia> isa(Dollar(2), Currency) # equivalent to typeof(Dollar(2)) <: Currency
true
As Currency
is an abstract type, we cannot create its instance. However, abstract types allow us to define generic functions that work for all their subtypes. We do so and define the BankAccount
composite type.
struct BankAccount{C<:Currency}
owner::String
transaction::Vector{Currency}
function BankAccount(owner::String, C::Type{<:Currency})
return new{C}(owner, Currency[C(0)])
end
end
We will explain this type after creating its instance with the euro currency.
julia> b = BankAccount("Paul", Euro)
BankAccount{Euro}("Paul", Currency[Euro(0.0)])
First, we observe that we use the Euro
type (and not its instance) to instantiate the BankAccount
type. The reason is the definition of the inner constructor for BankAccount
, where the type annotation is ::Type{<:Currency}
. This is in contrast with ::Currency
. The former requires that the argument is a type, while the latter needs an instance.
Second, BankAccount
is a parametric type, as can be seen from BankAccount{Euro}
. In our example, this parameter plays the role of the primary account currency.
Third, due to the line Currency[C(0)]
in the inner constructor, transactions are stored in a vector of type Vector{Currency}
. The expression C(0)
creates an instance of the currency C
with zero value. The Currency
type combined with the square brackets creates a vector that may contain instances of any subtypes of Currency
. It is, therefore, possible to push a new transaction in a different currency to the transaction
field.
julia> push!(b.transaction, Dollar(2))
2-element Vector{Currency}:
Euro(0.0)
Dollar(2.0)
julia> b
BankAccount{Euro}("Paul", Currency[Euro(0.0), Dollar(2.0)])
It is crucial to use Currency
in Currency[C(0)]
. Without it, we would create an array of type C
only. We would not be able to add transactions in different currencies to this array as Julia could not convert the different currencies to C
.
julia> w = [Euro(0)]
1-element Vector{Euro}:
Euro(0.0)
julia> push!(w, Dollar(2))
ERROR: MethodError: Cannot `convert` an object of type Dollar to an object of type Euro
[...]
We used only the abstract type Currency
to define the BankAccount
type. This allows us to write a generic code that not constrained to one concrete type. We created an instance of BankAccount
and added a new transaction. However, we cannot calculate an account balance (the sum of all transactions), and we cannot convert money from one currency to another. In the rest of the lecture, we will fix this, and we will also define basic arithmetic operations such as +
or -
.
It is generally not good to use containers with abstract element type as we did for storing transactions. We used it in the example above because we do not want to convert all transactions to a common currency. When we create an array from different types, the promotion system converts these types to their smallest supertype for efficient memory storage.
julia> [Int32(123), 1, 1.5, 1.234f0]
4-element Vector{Float64}:
123.0
1.0
1.5
1.2339999675750732
The smallest supertype is Float64
, and the result is Array{Float64, 1}
. When we do not want to convert the variables, we must manually specify the resulting array supertype.
julia> Real[Int32(123), 1, 1.5, 1.234f0]
4-element Vector{Real}:
123
1
1.5
1.234f0
In this case, the types of all elements are preserved.
Custom print
Each currency has its symbol, such as € for the euro. We will redefine the show
function to print the currency in a prettier way. First, we define a new function symbol
that returns the used currency symbol.
symbol(T::Type{<:Currency}) = string(nameof(T))
symbol(::Type{Euro}) = "€"
We defined one method for all subtypes of Currency
and one method for the Euro
type. With the symbol
function, we can define nicer printing by adding a new method to the show
function from Base
. It is possible to define a custom show function for different output formats. For example, it is possible to define different formating for HTML output. The example below shows only basic usage; for more information, see the official documentation.
Base.show(io::IO, c::C) where {C <: Currency} = print(io, c.value, " ", symbol(C))
The show
function has two input arguments. The first one is of type IO
that specifies where the output will be printed (for example, in the REPL). The second argument is an instance of some currency. We used the where
keyword in the function definition to get the currency type C
, which we pass to the symbol
function. Alternatively, we can use the typeof
function.
Base.show(io::IO, c::Currency) = print(io, c.value, " ", symbol(typeof(c)))
We can check that the printing of currencies is prettier than before.
julia> Euro(1)
1.0 €
julia> Euro(1.5)
1.5 €
There is one big difference with Python, where we can create a class and define methods inside the class. If we wanted to add a new method, we have to would modify the class. In Julia, we can add or alter methods any time without the necessity to change the class.
Define a new method for the symbol
function for Dollar
.
Hint: the dollar symbol $
has a special meaning in Julia. Do not forget to use the \
symbol when using the dollar symbol in a string.
Solution:
When adding a new method to the symbol
function, we have to remember that we used the currency type for dispatch, i.e., we have to use ::Type{Dollar}
instead of ::Dollar
in the type annotation.
symbol(::Type{Dollar}) = "\$"
Now we can check that everything works well.
julia> Dollar(1)
1.0 $
julia> Dollar(1.5)
1.5 $
Conversion
In the previous section, we have defined two currencies. A natural question is how to convert one currency to the other. In the real world, the exchange operation between currencies is not transitive. However, we assume that the exchange rate is transitive and there are no exchange costs.
The simplest way to define conversions between the currencies is to define the conversion function for each pair of currencies. This can be done efficiently only for two currencies.
dollar2euro(c::Dollar) = Euro(0.83 * c.value)
euro2dollar(c::Euro) = Dollar(c.value / 0.83)
We can check that the result is correct.
julia> eur = dollar2euro(Dollar(1.3))
1.079 €
julia> euro2dollar(eur)
1.3 $
Even though this is a way to write code, there is a more general way. We start with a conversion rate between two types.
rate(::Type{Euro}, ::Type{Dollar}) = 0.83
Transitivity implies that if one exchange rate is $r_{1 \rightarrow 2}$, the opposite exchange rate equals $r_{2 \rightarrow 1} = r_{1 \rightarrow 2}^{-1}$. We create a generic function to define the exchange rate in the opposite direction.
rate(T::Type{<:Currency}, ::Type{Euro}) = 1 / rate(Euro, T)
If we use only the two methods above, it computes the exchange rate between Dollar
and Euro
.
julia> rate(Euro, Dollar)
0.83
julia> rate(Dollar, Euro)
1.2048192771084338
However, the definition is not complete because the rate
function does not work if we use the same currencies.
julia> rate(Euro, Euro)
ERROR: StackOverflowError:
[...]
julia> rate(Dollar, Dollar)
ERROR: MethodError: no method matching rate(::Type{Dollar}, ::Type{Dollar})
[...]
To solve this issue, we have to add two new methods. The first one defines that the exchange rate between the same currency is 1
.
rate(::Type{T}, ::Type{T}) where {T<:Currency} = 1
This method solves the issue for the Dollar
to Dollar
conversion.
julia> rate(Dollar, Dollar)
1
However, it does not solve the problem with Euro
to Euro
conversion.
julia> rate(Euro, Euro)
ERROR: StackOverflowError:
[...]
The reason is that methods are selected based on the input arguments. There is a simple rule: the most specific method definition matching the number and types of the arguments will be executed. We use the methods
function to list all methods defined for the rate
function.
julia> methods(rate)
# 3 methods for generic function "rate":
[1] rate(::Type{Euro}, ::Type{Dollar}) in Main at none:1
[2] rate(T::Type{var"#s37"} where var"#s37"<:Currency, ::Type{Euro}) in Main at none:1
[3] rate(::Type{T}, ::Type{T}) where T<:Currency in Main at none:1
There are three methods. Since two of them can be selected when converting from euro to euro, we need to specify one more method.
rate(::Type{Euro}, ::Type{Euro}) = 1
This method solves the issue, as can be seen in the example below.
julia> rate(Euro, Euro)
1
The transitivity also implies that instead of converting the C1
currency directly to the C2
currency, we can convert it to some C
and then convert C
to C2
. In our case, we use the Euro
as the intermediate currency. When adding a new currency, it suffices to specify its exchange rate only to the euro.
rate(T::Type{<:Currency}, C::Type{<:Currency}) = rate(Euro, C) * rate(T, Euro)
To test the rate
function, we add a new currency.
struct Pound <: Currency
value::Float64
end
symbol(::Type{Pound}) = "£"
rate(::Type{Euro}, ::Type{Pound}) = 1.13
We can quickly test that the rate
function works in all possible cases correctly in the following way.
julia> rate(Pound, Pound) # 1
1
julia> rate(Euro, Pound) # 1.13
1.13
julia> rate(Pound, Euro) # 1/1.13 = 0.8849557522123894
0.8849557522123894
julia> rate(Dollar, Pound) # 1.13 * 1/0.83 = 1.36144578313253
1.3614457831325302
julia> rate(Pound, Dollar) # 0.83 * 1/1.13 = 0.7345132743362832
0.7345132743362832
We have defined the rate
function with all necessary methods. To convert currency types, we need to extend the convert
function from Base
by the following two methods:
Base.convert(::Type{T}, c::T) where {T<:Currency} = c
Base.convert(::Type{T}, c::C) where {T<:Currency, C<:Currency} = T(c.value * rate(T, C))
The first method is unnecessary because the rate
function returns 1
, and the second method could be used instead. However, when converting to the same type, the result is usually the same object and not a new instance. We, therefore, defined the first method to follow this convention. Finally, we test that the conversion
function indeed converts its input to a different type.
julia> eur = convert(Euro, Dollar(1.3))
1.079 €
julia> pnd = convert(Pound, eur)
0.9548672566371682 £
julia> dlr = convert(Dollar, pnd)
1.3 $
The printing style is not ideal because we are usually not interested in more than the first two digits after the decimal point. Redefine the method in the show
function to print currencies so that the result is rounded to 2 digits after the decimal point.
Solution:
Any real number can be rounded to 2 digits after the decimal point by the round
function with the keyword argument digits = 2
. Then we can use an almost identical definition of the method as before.
function Base.show(io::IO, c::T) where {T <: Currency}
val = round(c.value; digits = 2)
return print(io, val, " ", symbol(T))
end
The same code as before this example gives the following results.
julia> eur = convert(Euro, Dollar(1.3))
1.08 €
julia> pnd = convert(Pound, eur)
0.95 £
julia> dlr = convert(Dollar, pnd)
1.3 $
We realize that the rounding is done only for printing, while the original value remains unchanged.
Promotion
Before defining basic arithmetic operations for currencies, we have to decide how to work with money in different currencies. Imagine that we want to add 1€
and 1$
. Should the result be euro or dollar? For such a situation, Julia provides a promotion system that allows defining simple rules for promoting custom types. The promotion system can be modified by defining custom methods for the promote_rule
function. For example, the following definition means that the euro has precedence against all other currencies.
Base.promote_rule(::Type{Euro}, ::Type{<:Currency}) = Euro
One does not need to define both methods. The symmetry is implied by the way promote_rule
is used in the promotion process. Since we have three different currencies, we also define the promotion type for the pair Dollar
and Pound
.
Base.promote_rule(::Type{Dollar}, ::Type{Pound}) = Dollar
The promote_rule
function is used as a building block for the promote_type
function, which returns the promoted type of inputs.
julia> promote_type(Euro, Dollar)
Euro
julia> promote_type(Pound, Dollar)
Dollar
julia> promote_type(Pound, Dollar, Euro)
Euro
When we have instances instead of types, we can use the promote
function to convert them to their representation in the promoted type.
julia> promote(Euro(2), Dollar(2.4))
(2.0 €, 1.99 €)
julia> promote(Pound(1.3), Euro(2))
(1.47 €, 2.0 €)
julia> promote(Pound(1.3), Dollar(2.4), Euro(2))
(1.47 €, 1.99 €, 2.0 €)
Define a new currency CzechCrown
representing Czech crowns. The exchange rate to euro is 0.038
, and all other currencies should take precedence over the Czech crown.
Solution:
We define first the new type CzechCrown
.
struct CzechCrown <: Currency
value::Float64
end
We must add new methods for the symbol
and rate
functions.
symbol(::Type{CzechCrown}) = "Kč"
rate(::Type{Euro}, ::Type{CzechCrown}) = 0.038
We also must add promotion rules for the dollar and pound.
Base.promote_rule(::Type{CzechCrown}, ::Type{Dollar}) = Dollar
Base.promote_rule(::Type{CzechCrown}, ::Type{Pound}) = Pound
Finally, we can test the functionality.
julia> CzechCrown(2.8)
2.8 Kč
julia> dl = convert(Dollar, CzechCrown(64))
2.93 $
julia> convert(CzechCrown, dl)
64.0 Kč
julia> promote(Pound(1.3), Dollar(2.4), Euro(2), CzechCrown(2.8))
(1.47 €, 1.99 €, 2.0 €, 0.11 €)
Basic arithmetic operations
Now we are ready to define basic arithmetic operations. As usual, we can do this by adding a new method to standard functions. We start with the addition, where there are two cases to consider. The first one is the summation of two different currencies. In this case, we use the promote
function to convert these two currencies to their promote type.
Base.:+(x::Currency, y::Currency) = +(promote(x, y)...)
The second one is the summation of the same currency. In this case, we know the resulting currency, and we can sum the value
fields.
Base.:+(x::T, y::T) where {T <: Currency} = T(x.value + y.value)
Note that the first function calls the second one. We have finished with addition, and now we can sum money in different currencies.
julia> Dollar(1.3) + CzechCrown(4.5)
1.51 $
julia> CzechCrown(4.5) + Euro(3.2) + Pound(3.6) + Dollar(12)
17.4 €
Moreover, we can use, for example, the sum
function without any additional changes.
julia> sum([CzechCrown(4.5), Euro(3.2), Pound(3.6), Dollar(12)])
17.4 €
Also, the broadcasting works natively for arrays of currencies.
julia> CzechCrown.([4.5, 2.4, 16.7, 18.3]) .+ Pound.([1.2, 2.6, 0.6, 1.8])
4-element Vector{Pound}:
1.35 £
2.68 £
1.16 £
2.42 £
However, there is a problem if we want to sum a vector of currencies with one currency. In such a case, an error will occur.
julia> CzechCrown.([4.5, 2.4, 16.7, 18.3]) .+ Dollar(12)
ERROR: MethodError: no method matching length(::Main.Dollar)
[...]
The reason is that Julia assumes that custom structures are iterable. But in our case, all subtypes of the Currency
type represent scalar values. This situation can be easily fixed by defining a new method to the broadcastable
function from Base
.
Base.broadcastable(c::Currency) = Ref(c)
This function should return either an object or some representation of an object that supports the axes
, ndims
, and indexing
functions. To create such a representation of all subtypes of the Currency
type, we use the Ref
function, which creates an object referring to the given currency instance and supports all necessary operations.
julia> c_ref = Ref(Euro(1))
Base.RefValue{Euro}(1.0 €)
julia> axes(c_ref)
()
julia> ndims(c_ref)
0
julia> c_ref[]
1.0 €
Now we can test if the broadcasting works as expected.
julia> CzechCrown.([4.5, 2.4, 16.7, 18.3]) .+ Dollar(12)
4-element Vector{Dollar}:
12.21 $
12.11 $
12.76 $
12.84 $
In the section above, we defined the addition for all subtypes of Currency
. We also told the broadcasting system in Julia to treat all subtypes of the Currency
as scalars. Follow the same pattern and define the following operations: -
, *
, /
.
Hint: Define only operations that make sense. For example, it makes sense to multiply 1 €
by 2 to get 2 €
. But it does not make sense to multiply 1 €
by 2 €
.
Solution:
The -
operation can be defined exactly as the addition.
Base.:-(x::Currency, y::Currency) = -(promote(x, y)...)
Base.:-(x::T, y::T) where {T <: Currency} = T(x.value - y.value)
In the example below, we can see that everything works as intended.
julia> Dollar(1.3) - CzechCrown(4.5)
1.09 $
julia> CzechCrown.([4.5, 2.4, 16.7, 18.3]) .- Dollar(12)
4-element Vector{Dollar}:
-11.79 $
-11.89 $
-11.24 $
-11.16 $
The situation with the multiplication is different as it makes sense to multiply 1 €
by 2 but not by 2 €
. We have to define a method for multiplying any Currency
subtype by a real number. We have to define the multiplication both from the right and the left.
Base.:*(a::Real, x::T) where {T <: Currency} = T(a * x.value)
Base.:*(x::T, a::Real) where {T <: Currency} = T(a * x.value)
As in the previous cases, everything works as expected, and broadcasting is supported without any additional steps.
julia> 2 * Dollar(1.3) * 0.5
1.3 $
julia> 2 .* CzechCrown.([4.5, 2.4, 16.7, 18.3]) .* 0.5
4-element Vector{CzechCrown}:
4.5 Kč
2.4 Kč
16.7 Kč
18.3 Kč
Finally, we can define division. In this case, it makes sense to divide a currency by a real number.
Base.:/(x::T, a::Real) where {T <: Currency} = T(x.value / a)
But it also makes sense to define the division of one amount of money by another amount of money in different currencies. In this case, a result is a real number representing their ratio.
Base.:/(x::Currency, y::Currency) = /(promote(x, y)...)
Base.:/(x::T, y::T) where {T <: Currency} = x.value / y.value
The result is as follows.
julia> Dollar(1.3) / 2
0.65 $
julia> 2 .* CzechCrown.([1, 2, 3, 4]) ./ CzechCrown(1)
4-element Vector{Float64}:
2.0
4.0
6.0
8.0
Currency comparison
The last thing we should define is comparison operators. To provide full functionality, we have to add new methods to two functions. The first one is the value equality operator ==
. By default, it uses the following definition ==(x, y) = x === y
. The ===
operator determines whether x
and y
are identical, in the sense that no program could distinguish them.
julia> Dollar(1) == Euro(0.83)
false
julia> Dollar(1) != Euro(0.83)
true
The result does not match the expected behaviour since 0.83 €
is equal to 1 $
with the given exchange rate. The reason is that we want to compare values stored in the structures and not the structures themselves. To allow this kind of comparison, we can define new methods to the ==
function as follows:
Base.:(==)(x::Currency, y::Currency) = ==(promote(x, y)...)
Base.:(==)(x::T, y::T) where {T <: Currency} = ==(x.value, y.value)
Again, the first function (for different currencies) calls the second function (for the same currencies). With these two methods defined, the comparison works as expected.
julia> Dollar(1) == Euro(0.83)
true
julia> Dollar(1) != Euro(0.83)
false
The second function to extend is the isless
function. In this case, the logic is the same as before: We want to compare values stored in the structure.
Base.isless(x::Currency, y::Currency) = isless(promote(x, y)...)
Base.isless(x::T, y::T) where {T <: Currency} = isless(x.value, y.value)
As can be seen below, all operations work as intended.
julia> Dollar(1) < Euro(0.83)
false
julia> Dollar(1) > Euro(0.83)
false
julia> Dollar(1) <= Euro(0.83)
true
julia> Dollar(1) >= Euro(0.83)
true
Other functions based only on comparison will work for all subtypes of Currency
without any additional changes. Examples include extrema
, argmin
or sort
functions.
julia> vals = Currency[CzechCrown(100), Euro(0.83), Pound(3.6), Dollar(1.2)]
4-element Vector{Currency}:
100.0 Kč
0.83 €
3.6 £
1.2 $
julia> extrema(vals)
(0.83 €, 3.6 £)
julia> argmin(vals)
2
julia> sort(vals)
4-element Vector{Currency}:
0.83 €
1.2 $
100.0 Kč
3.6 £
Back to the bank account
In the previous sections, we defined all the functions and types needed for the BankAccount
type and performed basic arithmetic and other operations on currencies. For a bank account, we are primarily interested in its balance. Since we store all transactions in a vector, the account balance can be computed as a sum of the transaction
field.
balance(b::BankAccount{C}) where {C} = convert(C, sum(b.transaction))
We convert the balance to the primary currency of the account.
julia> b = BankAccount("Paul", CzechCrown)
BankAccount{CzechCrown}("Paul", Currency[0.0 Kč])
julia> balance(b)
0.0 Kč
Another thing that we can define is custom pretty-printing.
function Base.show(io::IO, b::BankAccount{C}) where {C<:Currency}
println(io, "Bank Account:")
println(io, " - Owner: ", b.owner)
println(io, " - Primary currency: ", nameof(C))
println(io, " - Balance: ", balance(b))
print(io, " - Number of transactions: ", length(b.transaction))
end
The previous method definition results in the following output.
julia> b
Bank Account:
- Owner: Paul
- Primary currency: CzechCrown
- Balance: 0.0 Kč
- Number of transactions: 1
The last function that we define is the function that adds a new transaction into the given bank account. Even though it can be defined like any other function, we decided to use a special syntax. Since methods are associated with types, making any arbitrary Julia object "callable" is possible by adding methods to its type. Such "callable" objects are sometimes called "functors".
function (b::BankAccount{T})(c::Currency) where {T}
balance(b) + c >= T(0) || throw(ArgumentError("insufficient bank account balance."))
push!(b.transaction, c)
return
end
The first thing in the function above is the check whether there is a sufficient account balance. If not, the function will throw an error. Otherwise, the function will push a new element to the transaction
field.
julia> b(Dollar(10))
julia> b(-2*balance(b))
ERROR: ArgumentError: insufficient bank account balance.
[...]
julia> b(Pound(10))
julia> b(Euro(23.6))
julia> b(CzechCrown(152))
julia> b
Bank Account:
- Owner: Paul
- Primary currency: CzechCrown
- Balance: 1288.84 Kč
- Number of transactions: 5
Note that all transactions are stored in their original currency, as can be seen if we print the transaction
field.
julia> b.transaction
5-element Vector{Currency}:
0.0 Kč
10.0 $
10.0 £
23.6 €
152.0 Kč