Acceptable pattern when two structs of the same type share the same fields

Consider the following structs:

abstract type AbstractTransport end

struct Warp
    limit::Float32
end

struct Galaxy <:AbstractTransport
    name::String
    drive::Warp
end

struct Intrepid <:AbstractTransport
    name::String
    drive::Warp
end

Both Galaxy and Intrepid share the same field names of name::String and drive::Warp. On the surface it isn’t too bad, but consider if the number of fields grow and the introduction of an inner constructor for validation, that’s a lot of code that would be repeated over again.

Is there an acceptable pattern to handle this? The only work around I can think of is to separate the common fields into another struct, ShipDetails:

struct ShipDetails
    name::String
    drive::Warp

    function ShipDetails(name::String, drive::Warp)
     # validation
     new(name,drive)
    end 
end

I can restructure the structs like this:


struct Galaxy <:AbstractTransport
    attributes::ShipDetails
end

struct Intrepid <:AbstractTransport
    attributes::ShipDetails
end
3 Likes

I basically made GitHub - marius311/CompositeStructs.jl for this exact issue. It’s definitely not a super established solution (the package isn’t even registered), but it has worked well for me thus far and I’m curious to keep developing it. From the readme:

Splices the fields of one struct into another. E.g.:

struct Foo{X,Y}
    x :: X
    y :: Y
end

@composite struct Bar{X,Y,Z}
    Foo{X,Y}...
    z :: Z
end

# equivalent to defining:
struct Bar{X,Y,Z}
    x :: X
    y :: Y
    z :: Z
end

There’s a couple other similar packages out there mentioned in the readme, although this has the unique feature that you don’t need to do anything special to the “parent” struct.

5 Likes

I did this below once. I do not think that is very pretty, but in particular if there is any clear reason NOT do it, I will be glad to know.

julia> macro MyFields()
         esc(quote
           x::Float64
           y::Float64
         end)
       end
@MyFields (macro with 1 method)

julia> struct A
         @MyFields()
       end

julia> struct B
         @MyFields()
         z::Float64
       end

julia> fieldnames(A)
(:x, :y)

julia> fieldnames(B)
(:x, :y, :z)


I think this is a good approach. It doesn’t require any tools, and it makes the set of shared fields explicit. You can also use @forward from Lazy.jl to make it easier to access the fields inside attributes without having to do ship.attributes.drive.

The composition approach that you’ve described also makes it easy to see how you might create multiple different sets of shared attributes (after all, there are probably some fields in common to a warp-capable ship and other fields in common for a combat ship) without having to worry about multiple inheritance and the complexities that it creates in other languages.

9 Likes

Using functions to retrieve the properties is quite clear and practical as well:

julia> ship = Galaxy(ShipDetails("ship",Warp(1.0)))
Galaxy(ShipDetails("ship", Warp(1.0f0)))

julia> name(x::AbstractTransport) = x.attributes.name
name (generic function with 1 method)

julia> name(ship)
"ship"

3 Likes

I would also go with composition, or possibly a field (see below, preferable) or a type parameter:

using ArgCheck

struct Galaxy end

struct Intrepid end

struct Ship{T}
    kind::T
    name::String
    drive::Warp
    function Ship(kind::T, name, drive) where T
        @argcheck warp <= 10 "you must be desperate for a plot device"
        new{T}(kind, name, drive)
    end
end

Ship(Galaxy(), "Enterprise-D", 9.6)

You can also make kinds a subtype of some abstract type, etc.

4 Likes

If all AbstractTransport subtypes have the same fields, you could also turn AbstractTransport into a concrete type and use a type parameter to distinguish different kinds:

struct Transport{Kind}
    name::String
    drive::Warp
end

const Galaxy = Transport{:Galaxy}
const Intrepid = Transport{:Intrepid}
2 Likes

I also mentioned that as an option above, but IMO having an actual field is much more convenient. And of course costless, for singleton types.

When in doubt, a good rule of thumb is to work with values, not types.

3 Likes

I agree that a field is more convenient, since you can then decide at any time that Galaxy shouldn’t be a singleton type anymore.

1 Like

An approach I’ve used is the following:

abstract type AbstractTransport end

transports = [:Galaxy, :Intrepid]

struct Warp
    limit::Float32
end

for transport in transports
    @eval struct $transport <: AbstractTransport
        name::String
        drive::Warp
    end
end

What I like about it is that if you come up with another ship name, you can just add it to transports. You can use the structs the same way as in your definition:

julia> Galaxy("ship", Warp(10))
Galaxy("ship", Warp(10.0f0))

I’ll admit that having 2 separate structs simply because a ship is a different class is overkill. Perhaps a better idea would be StarShip <:AbstractTransport and CruiseShip <: AbstractTransport. Both differ in function to be different structs.

Your solution is very appropriate.

Just as an example of another approach,
here is a very shallow abstract type chain.
(not really appropriate in this small example, still good to know)


abstract type AbstractTransport end
abstract type StellarTransport  <: AbstractTransport end
abstract type PlanetaryTransport  <: AbstractTransport end

struct Warp
    limit::Float32
end

struct StarShip <: StellarTransport
   name::String
   drive::Warp
end

struct CruiseShip <: PlanetaryTransport
  name::String
  drive::Warp
end

galaxy = StarShip("Galaxy", Warp(10))
cruiser = CruiseShip("Cruiser", Warp(2))

function maxwarp(ship::StellarTransport)
    return Warp(ship.drive.limit * 7/8)
end

function maxwarp(ship::PlanetaryTransport)
    return Warp(ship.drive.limit * 5/8)
end
1 Like

I was just thinking about this not too long ago! Very nice.

In this case, I would make Warp into Engine or better, make it abstract. Inside CruiseShip, I would make an Engine[], they might have a primary and backup engine, and in StellarTransport, I would do the same, because you often hear warp or impulse engines.

Can you expand a bit on the use of @forward? I wanted to use this solution but accessing the attributes like

intrepid.name

instead of

intrepid.attributes.name

but it does not look like @forward can do that, right?

one way to do this would be you would create accessor functions like this

name(sd::ShipDetails) = sd.name 
drive(sd::ShipDetails) = sd.drive

then forward these calls to Galaxy.attributes and Intrepid.attributes like so

using Lazy
@forward Galaxy.attributes name, drive
@forward Intrepid.attributes name, drive

so now you can write

name(my_galaxy)
name(my_intrepid)

I like @lmiq’s macro approach too

1 Like