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 and transaction.
  • 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 -.

Avoid containers with abstract type parameters:

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.

Exercise:

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 $
Exercise:

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 €)
Exercise:

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 $
Exercise:

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č