Is it ok to write interfaces that require field access on subtypes?

Hello!

Since we can’t define fields on abstract types (at least yet, see this very long discussion), I was wondering whether the following design pattern is considered good practice in Julia:

abstract type A end

struct B <: A
    x::Int
end

getx(a::A) = a.x

I’m asking because if the following struct is later defined:

struct C <: A end

then the call to getx(C()) will not work, thus breaking the Liskov substitution principle.

Is this not a concern?

The alternative of course would be to write individual getters/setters for each struct, which quickly becomes a lot of boiler-plate code.

How one should go about this?

Cheers

3 Likes

Hi, welcome to the forum :slight_smile:

One aspect to keep in mind is that Julia uses a functional approach with multiple dispatch, rather than object oriented programming. Therefore, some of the principles from OOP don’t translate well into Julia.

A common approach in Julia is to work with so-called interfaces. E.g. informally defined sets of methods each subtype is expected to have. You are right that this can lead to a bit more code here and there, but it brings other advantages (well explained in the video I linked below.)

It is not so common to write getter/setter methods in Julia. If you do so, often getproperty/setproperty are your friends. In your case, that could be that you (for yourself) make the rule that each subtype of A should have a well-defined behaviour for getproperty(a, :x).

Notice that multiple dispatch gives you more freedom (which has advantages and disadvantages; it’s just a different approach :wink: ). Here is a quick example:

abstract type A end
two_times_x(a::A) = 2*a.x
x_squared(a::A) = a.x^2

mutable struct B <: A
    x::Int
end

mutable struct C <: A 
    x²::Int 
end

Base.getproperty(c::C, s::Symbol) = s == :x ? Int(sqrt(c.x²)) : getfield(c, s)
Base.setproperty!(c::C, s::Symbol, v) = s == :x ? (c.x² = v^2) : setfield!(c, s, v)
x_squared(c::C) = c.x²

c = C(64)
@show c.x # == 8
c.x = 9   # uses setproperty
@show c.x # == 9

b = B(8)
@show b.x  # == 8

two_times_x(b), two_times_x(c)  # == (16, 18)
x_squared(b), x_squared(c)      # == (64, 81)

As you can see, you have the freedom the implement subtypes which don’t even have the field x but maybe just an abstract representation of the data. But externally they still behave compatible.


Some other links:

9 Likes

Hey Steffen!

Thanks for the answer, that was really helpful.

Just to wrap things up and make sure I understood what you said so I can mark your answer as a solution:

Sometimes part of the interface of a type involves getting access to one of its properties.

For example, if I have an abstract type Vehicle I might want to include in my interface a method model(v::Vehicle) that, for most concrete Vehicles, will just access a field in the struct. Not necessarily all concretes of Vehicle have this field, however.

Therefore I could define my interface as such:

abstract type Vehicle end
model(v::Vehicle) = v.model

struct Car <: Vehicle
    age::Int
    model::String
end

struct Bus <: Vehicle
    age::Int
    model::String
end

# Not worth saving the model as a property, I know its the same for every instance of the struct
struct ToyotaCorolla <: Vehicle
    age:Int
end
model(tc::ToyotaCorolla) = "Corolla"

Is this reasonable or should I not “rely” of the field access and instead go for:

abstract type Vehicle end

struct Car <: Vehicle
    age::Int
    model::String
end
model(c::Car) = c.model

struct Bus <: Vehicle
    age::Int
    model::String
end
model(b::Bus) = b.model

struct ToyotaCorolla <: Vehicle
    age:Int
end
model(tc::ToyotaCorolla) = "Corolla"
2 Likes

The first variant looks best :+1: Define a generic method that works in most cases and let special cases specialize that function.

7 Likes

This pattern,

abstract type Vehicle end
model(v::Vehicle) = v.model

is ok if you are the author of all the subtypes of Vehicle, but if other people will be extending the Vehicle interface with their own subtypes of Vehicle, then it is better to use your second option above. Julia interfaces typically do not require subtypes to have specific fields.

For instance, suppose I define my own Vehicle subtype like this:

struct HondaCivic <: Vehicle
    age:Int
end

If I forget to extend model for HondaCivic, I will get an ERROR: type HondaCivic has no field model when model is called on a HondaCivic. But it is more idiomatic to get a MethodError if a method is not implemented for a type. In this case, model is in fact implemented for HondaCivic via the model(v::Vehicle) default method—it just happens to be incorrectly implemented for HondaCivic.

Methods on abstract types make more sense if they only call generic functions on their arguments. Here’s an example:

abstract type AbstractRectangle end

area(r::AbstractRectangle) = len(r) * wid(r)

struct Rectangle <: AbstractRectangle
    length::Float64
    width::Float64
end

len(r::Rectangle) = r.length
wid(r::Rectangle) = r.width

struct Square <: AbstractRectangle
    width::Float64
end

len(s::Square) = s.width
wid(s::Square) = s.width

Note that I have to give concrete definitions of len and wid for each of the concrete subtypes of AbstractRectangle.

9 Likes

There is at least one instance in Base where field access is used: OrdinalRange

Note that you are almost never doing that, unless you use getfield explicitly. The . syntax is lowered to getproperty, which happens to fall back to getfield by default. But it does not have to.

The only slightly cumbersome thing about getproperty is that it takes a value, so you cannot add “methods” to it. For simple cases, people use an if (see the manual), but if you insist you can do

Base.getproperty(x::MyType, key) = my_getproperty(x, Val(key))

my_getproperty(x::MyType, ::Val{:some_field}) = ...

and it will work fine.

That said,

may just signal a broader issue with your API. If most structs are “containers”, then accessing their fields directly is fine IMO, and working around the occasional exception with getproperty etc is OK. But if a nontrivial type hierarchy has the same pattern and you need a lot of extras (calculating fields, checking valid values, etc), then exposing field access with a function could be an idiomatic choice. YMMV.

2 Likes