Best Practices for Mostly Immutable Composite Types

Hello,

First off, thank you to everyone that contributes to the Julia ecosystem! The more I read the more I become impressed with the size and expertise of the community. As a heavy Matlab user I am excited to move what work I can towards Julia and hopefully get to a place where I can contribute back to the community.

My question here involves the best practices for utilizing composite types in which all but one or two of the fields are immutable.

For example, suppose I am trying to model an airplane for use within a larger swarm simulation. I have had success in implementing something like:

using StaticArrays

mutable struct SimpleAirplane
    # these fields never change after initialization:
    wing_area::Float64
    mass::Float64
    CL::Float64
    CD::Float64
    #more fields...
    #...
    #...
    # these fields change often:
    X::MVector{3, Float64}
    V::MVector{3, Float64} 
end

function move!(A::SimpleAirplane,dt::Number)
    A.X = A.X + A.V*dt
end


A = SimpleAirplane(1,10,1,0.1,[0,0,-100],[20,0,0])
move!(A,1.0)

I have seen suggestions around the forums and within the user’s manual on using immutable types where possible to facilitate performance. While this is not really an issue for me currently I want to adopt best practices wherever I can. As such, I was wondering if there is a better way to approach this kind of problem? I guess my concrete questions would be:

  • Is a mutable struct the appropriate container for this use case? What other containers might make sense?
  • Is there a way to designate only a subset of fields as mutable?
  • How substantial (if at all) is the performance hit for keeping SimpleAirplane mutable?

Thanks!

1 Like

You don’t need to keep SimpleAirplane mutable at all. You never need to change the vectors X and V, just their contents. Immutable just refers to the value of the fields. For MVectors (or other mutable structures) that value is their address.

4 Likes

Seconding that.
As you already have mutable X and V, just mutate them:

A.X .+= A.V .* dt

The “dotted” syntax does in-place mutation and operation fusion automagically.

In other cases, when one wants a single mutable scalar field, I think, it’s fine to just go with a mutable struct. The key is to define the public interface via functions, and leave data fields an implementation detail.

The performance difference between mutable and immutable structs is hard to quantify for a general case. Immutable isbits structs are passed by value, so that big structs may be slow due to excessive copying. Mutables, of course, add an extra indirection, but are cheap to pass around.

Immutables are often thought of as a better alternative exactly because one can’t accidentally re-bind a data field holding a mutable type to another object instead of mutating it. Mutation is usually cheaper than allocating a new object, sometimes much cheaper.

6 Likes

My understanding is that this depends, and that the compiler is free to avoid copying of immutables in many cases.

5 Likes

It looks like the compiler isn’t always clever enough, especially when it comes to replacing an immutable object with its slightly modified copy.

But what I really wanted to say is that although immutable types are quite useful and must be the first thing to try, one shouldn’t take them as a silver bullet or a dogma or be afraid of being ashamed for using mutable types.

I would say that if you have only a couple fields in your struct that need to be mutable, I would just wrap them in a Ref. By playing around with getfield and getproperty, you can even make it act just like only one field was mutable:

struct Foo
    x::Int
    y::Float64
    z::Base.RefValue{Complex{Float64}} # This is the mutable field
    Foo(x, y, z::Complex) = new(x, y, Ref{Complex{Float64}}(z))
end
function Base.getproperty(f::Foo, s::Symbol)
    if s === :z
        getfield(f, :z)[]
    else
        getfield(f, s)
    end
end 
function Base.setproperty!(f::Foo, s::Symbol, x)
    if s === :z
        getfield(f, :z)[] = x
    else
        throw(ErrorException("Only the field z in Foo can be changed"))
    end
end
Base.show(io::IO, f::Foo) = print(io, "Foo($(f.x), $(f.y), $(f.z))")

Now at the REPL:

julia> f = Foo(1, 2.0, 3 + 2im)
Foo(1, 2.0, 3.0 + 2.0im)

julia> f.z
3.0 + 2.0im

julia> f.z = 5 + 2im
5 + 2im

julia> f
Foo(1, 2.0, 5.0 + 2.0im)

julia> f.x = 1
ERROR: Only the field z in Foo can be changed

I don’t think that anyone is being shamed for mutable composite types :wink:

But they have a cost that is not captured in simple benchmarks: mutable state almost always makes the code more complicated and harder to reason about. This means either much more careful work, or subtle bugs, which are usually tricky to find.

I find that it is usually worth it to pay even a small price in performance to avoid this. The great thing about Julia’s implementation is that this price is usually really tiny.

5 Likes