Struct/method definition order advice

In Julia, you can have

# in src/monster.jl
struct Monster
end

# in src/player.jl
struct Player
end
capture(::Player, ::Monster)

This will only work if include("src/player.jl") is done after include("src/monster.jl"). Otherwise, Julia complains that Monster isn’t defined. I understand the reason for that constraint, but nevertheless, it’s frustrating in large projects. What if I have some other method, attack(::Monster, ::Player) = ..., whose logic belongs more in monster.jl?

Shuffling code around is frequently a painful exercise because of this.

So I’m looking for advice. Is there some design pattern / perspective that helps? I’ve seen some packages throw the towel, with src/types.jl, but that is rather depressing.

4 Likes

You can do forward declarations for functions like so:

function capture end

I usually place these and their docstrings after struct definitions and implement the various methods afterwards, to not run into problems due to order of definition.

My issue is with the struct+method order definition. If you put

# in src/monster.jl
struct Monster
end
attack(::Monster, ::Player) = 1

# in src/player.jl
struct Player
end

and include monster.jl first, you get an error that Player isn’t defined. Forward declarations don’t help…

Yes - I’d have a file layout like this:

# src/structs.jl
struct Monster end

struct Player end

# src/attacks.jl
attack(::Monster, ::Player) = 1.0
attack(::Player, ::Monster) = 2.5

or something similar that results in this layout. Since functions and their arguments are not as tightly bound in julia as in e.g. Java or C++ (there is no implicit self argument), the way to resolve the definition order is to disentangle your data (the structs) from your operations (your functions). For example, why should attack be defined together with Monster and not Player (and the same goes vice versa)? Both arguments are equally important to decide which method is called, so associating it with either one or the other seems unnatural to me.

4 Likes

I think one possible solution is to define abstract types. For example:

# types.jl

abstract type AbstractMonster end
abstract type AbstractPlayer end

# monster.jl

struct Monster <: AbstractMonster
    ...
end

# player.jl

struct Player <: AbstractPlayer
    ...
end

function capture(player::AbstractPlayer, monster::AbstractMonster)
    ...
end

Then you just have to include types.jl first, and as long as you reference the abstract types in player.jl and monster.jl the order of includes doesn’t really matter between them.

1 Like

Yes, I think these abstract types are functionally equivalent to a forward type declaration! It’s a bit awkward, but after fighting to reorganize my code along other lines, I’m very willing to take that.

1 Like

It’s about keeping an algorithm readable in one page. Sometimes it makes sense to write avg_wait_time(::Patient) close to struct Patient, sometimes it’s nicer to have it next to wait_time(::Hospital), but the struct-before-method constraint is limiting.

This is a common issue people coming from OOP/single dispatch are upset about. In Julia and other multiple dispatch languages, data is completely disassociated from behavior, so it does not make much sense to put behavior next to data, since the behavior could apply to any combinations of data, and doing so doesn’t make it any more readable.

Instead, it makes much more sense to employ a modular design architecture, without this coupling.

3 Likes

There is also the FromFile.jl package that allows a more self-contained file structure, somewhat reminiscent of Python modules. I didn’t have much success with it, but the idea seems useful and lots of people use it.

Refactoring frequently reused code into a module can let you import/using repeatedly at the start of each file, so that may be a bit clearer than an include order in a distant primary file. But that module still has to exist in advance, often by an early include in that primary file, so that doesn’t really solve your problem. But I think this is less a constraint and just the fact that order matters to some degree.

The thread mentioned forward type declarations or delayed definitions (like how a callee function only needs to exist when it is called), and being someone who knows almost nothing about how dispatch is implemented, I find it plausible this could work for dispatch. But types do have to be fully defined before they’re put in fields; forward declarations can’t get around that. For example, you can’t make 2 mutually recursive isbits types like struct A b::B end; struct B a::A end; no matter what sort of forward declaration is made, it’s not possible for an A to directly contain B that contains another A that contains another B etc. This isn’t some quirk of Julia, it’s not possible in C++ either; a forward-declared type can only be pointed to or referenced before its full definition, and the Julian equivalent would be a Ref or abstractly annotated field. But when you need to change how a type is annotated or stored in some contexts just because you can’t be sure it was fully defined yet, that’s still order mattering, just with a bit more flexibility at the cost of complexity. I prefer sensible ordering and simpler code.

Yes, that is another constraint, but that one is easy to follow: just “define low-level types first”. The annoyance is that the methods/algos associated to these low-level types will occasionally want to refer to the higher-level types (i.e. foo(::LowerType, ::HigherType) = ...), and that doesn’t work without moving the whole algorithm “downstream” or splitting the algo across multiple pages or introducing abstract types.

In C++, as you said, I’d just use the forward type declaration class HigherType;.

1 Like

The idea that methods belong to types is much less clear in Julia than in OOP languages. I tend to think of generic functions as interfaces that get implemented, i.e., have methods, by different types and usually end up with something like the following structure:

  • interface_*.jl : Files containing interface defnitions, i.e., forward declarations of generic functions with docstrings describing their intend.
    Reading these files allows to see the abstract functionality at once.

  • type_*.jl: Files containing type declarations.
    Again, reading these files allows to see supported data types and their layout.

  • implementation_*.jl: Files containing the actual methods for each interface and type combination.
    Finally, I can dig into the implementation here if needed.

In practice the filenames would be more meaningful though, i.e., in your game example the files might be game.jl (for the interfaces), agents.jl(for the types) and game_play.jl (for the implementations) or something like that.
In simpler case, i.e., if a method only depends on a single type, it can also implement directly just after the type definition. Similarly, a generic function with a single method can be defined just at its point of use.
In any case, the capture(::Player, ::Monster) and foo(::LowerType, ::HigherType) methods belong neither to one nor the other type, i.e., they specify an interaction between types and should be handled accordingly.

I can appreciate the extra bit of flexibility with forward type declarations; if adapted to Julia and used to extremes, I might be able to forward declare all the types in any order, then define the types and functions in any order as long as the types themselves are defined in a “lower”-“higher” dependency order. But when type declarations need to precede everything and the type definitions have an order, I feel that I’m just adhering to a different kind of order rather than being more free, and that I might as well merge the ordered type definitions with the preceding type declarations. I also prefer if there weren’t many distant forward declarations I need to keep track of, though it wouldn’t be much trouble.

I do recognize that forward declarations would let you define multimethods physically close in the same file to the definition of the first argument’s type, but that informal association (well, if you put each type in its own module you could formally mimic class encapsulation) seems more like a singly-dispatched OOP pattern that isn’t needed for multiple dispatch in Julia.

1 Like