Mutable struct vs closure


#1

hello, suppose that I would like to maintain a vector that would be lengthened or shortened over time. Immutable struct could not do the job, so I need to use mutable struct. Then I need to build some methods that takes the mutable struct type as argument, e.g. shortened!(obj), lengthen!(obj, x).

Another way is to make the vector as a closure variable and returning closures like get(), shortened() and lengthen(x).

which approach is preferred? seems like the closure approach is more like traditional OOP, but it’s seldomly covered in Julia docs.

please discuss. thanks.


#2

It’s not very clear what you want. Could you please give more details? Eg. Why doesn’t Julia’s Vector type do what you want?


#3

for this particular example, yes, Vector would do.

But what I concern is to have an “object” that has some fields that changes over time. Then, there’re two ways doing that: define a mutable struct type and define ! methods for the type; or, use closures.

The closure approach, which seems unpopular in Julia, seems to give better “encapsulation”, because the captured variables could only be access thru the closures returned.

Or, is it a better “think Julia” way to do this?


#4

I would not worry about this. Simply adopt the habit that you only use accessor functions (which you need to define, and consider part of the interface), not fields directly. A mutable struct is easier to document, debug, dispatch on, etc.


#5

a simple example below to illustrate the idea:

###########################
# mutable struct approach
mutable struct CounterType
    count::Int64
end

function add1!(x::CounterType)
    x.count += 1
end

function minus1!(x::CounterType)
    x.count -= 1
end


###########################
# closure approach
function Counter(init::Int64)
    _count = init

    function get()
        return _count
    end

    function add1()
        _count += 1
    end

    function minus1()
        _count -= 1
    end

    return (get = get,
            add1 = add1,
            minus1 = minus1)
end

Now, both approaches would do the job:

julia> c = CounterType(3)
CounterType(3)
julia> c.count
3
julia> add1!(c)
4
julia> c.count
4
julia> minus1!(c)
3
julia> c.count
3

julia> (get, add1, minus1) = Counter(3)
(get = get, add1 = add1, minus1 = minus1)
julia> get()
3
julia> add1()
4
julia> get()
4
julia> minus1()
3
julia> get()
3

which approach should I use in general? I know that the mutable struct is more “Julia”-like, but as the fields are mutable, I just feel more “dangerous” as there’s no “encapsulation”…


#6

Not sure if I understand you correctly, but that seems like a wrong statement!

struct Test
   unchanging::Vector{Int}
end
x = Test([2,3,4])
resize!(x.unchanging, 10)

unchanging still refers to the same vector, only the mutable object itself changed - which is a very idiomatic way to encapsulate mutability :wink:
This is also idiomatic, especially if only few fields need mutability:

struct Test
   value::Ref{Int}
end
x = Test(Ref(1))
x.value[] = 2
x.value[] == 2

#7

I don’t see why this should be.


#8

because any method could modify the contents in a mutable struct.

while in the closure approach, the captured variables could only be accessed thru the closures given.


#9

while in the closure approach, the captured variables could only be accessed thru the closures given.

This is not true:

(get, add1, minus1) = Counter(3)

julia> a = get._count
Core.Box(3)

julia> a.contents
3

julia> a.contents = 4
4

julia> get()
4

Note that a closure is implemented as a struct. (Note also that Core.Box is bad news, related to a nasty issue about inference for closures.)

In my opinion the most “Julian” is probably a normal (immutable) struct:

struct MyCounter
    a::Int 
end 

my_add(x::MyCounter) = MyCounter(x.a + 1)

x = MyCounter(3)
my_add(x)

#10

If you really want encapsulation it is possible to overload getproperty and setproperty!

julia> mutable struct CounterType
           count::Int64
       end

julia> Base.getproperty(x::CounterType,::Symbol) = error("private fields")

julia> Base.setproperty!(x::CounterType,::Symbol,::Any) = error("cannot set private field")

julia> Base.propertynames(x::CounterType) = ()

This prevents the fields from being accessed or set with the dot notation for accessing fields.

julia> a = CounterType(5)
CounterType(5)

julia> a.count
ERROR: private fields
Stacktrace:
 [1] error(::String) at ./error.jl:33
 [2] getproperty(::CounterType, ::Symbol) at ./REPL[2]:1

getters and setters can then be constructed

julia> get(x::CounterType) = getfield(x,:count)
get (generic function with 1 method)

julia> set(x::CounterType,y::Int) = setfield!(x,:count,y)
set (generic function with 1 method)

julia> function minus1!(x::CounterType)
           set(x, get(x) -1)
       end
minus1! (generic function with 1 method)

julia> function add1!(x::CounterType)
           set(x,get(x) + 1)
       end
add1! (generic function with 1 method)

julia> add1!(a)
6

julia> add1!(a)
7

julia> minus1!(a)
6

#11

thanks.

after some trial-and-error, the following avoids:

  1. Core.Box
  2. accessing captured variables except using closures provided
function Counter(init::Int64)::NamedTuple{(:get, :add1, :minus1),
                                            NTuple{3, Function} }
    _count = Vector{Int64}(undef, 1)
    _count[1] = init

    local get, add1, minus1
    let _count = _count

    function get()::Int64
        return _count[1]
    end

    function add1()::Nothing
        _count[1] += 1
        return nothing
    end

    function minus1()::Nothing
        _count[1] -= 1
        return nothing
    end

    end

    return (get = get,
            add1 = add1,
            minus1 = minus1)
end

@code_warntype Counter(3)     # fine

julia> get._count
ERROR: type #get#38 has no field _count

#12

You can also reduce this to

julia> let count = 0
            global function add1()
                count += 1
             end
           end
add1 (generic function with 1 method)

julia> add1()
1

julia> add1()
2

#13

This does obfuscate it more, but you can always still use getfield method to access it anyway.

julia> getfield(get,:_count)
Core.Box(3)

#14

You need to understand that closures are not a first class concept in julia, they are implemented as structs during lowering. They are mere syntactic sugar above callable structs. All the encapsulation through closures is a mirage.

The only question is: What is the most transparent and friendly way of obtaining the desired interface and @code_native?

Once you are willing to spend the time to think about what you are writing (instead of having a one-liner just work, which is admittedly awesome), closures are useless, and explicit (mutable or immutable) structs always win.

Your latest closure/Vector-based variant introduces multiple useless pointer indirections plus wasted memory.

Hence, mutable struct Counter _cnt::Int64 end should be the thing, plus documentation that the internal layout of Counter is not part of the stable API. If any downstream users of your code still use the internal layout, well, they have been warned.

Nannying your downstream by Base.getproperty(::Counter, args...) = error("don't access me") and Base.setproperty!(::Counter, args...)=error("don't access! me") is imo just mean.


#15

In the same sense you can do the same in other languages with “private” fields, for example in C++ you can cast it to char *. The point of encapsulation seems more about making it difficult to accidentally modify/observe a private field then about guarantees that a “non-member” function can’t be made that can access it.

Also your code example seems to be based on a different attempt at encapsulation, my example does not use Core.Box, nor has a field with the name :_count

julia> getfield(get,:_count)
ERROR: type #get has no field _count
Stacktrace:
 [1] top-level scope at none:0

julia> getfield(a,:count)
5

julia> @code_typed get(a)
CodeInfo(
1 ─ %1 = (Main.getfield)(x, :count)::Int64
└──      return %1
) => Int64

#16

The main disadvantage I see with mutable struct is that its state can change, so it makes code more complicated to reason about, and less safe. This closure approach does not seem to address that, in fact it makes it easy to create bugs like this:

# a function intended to return a counter starting at 100
const COUNTER_100 = Counter(100)
get_counter_100() = COUNTER_100

# testing the above function
c = get_counter_100()
c.get()   # returns 100
c.add1()
c.get()   # returns 101

# testing it again
d = get_counter_100()
d.get()   # oops! returns 101

Furthermore, the closure approach may lead to code like this:

(get, add1, minus1) = Counter(3)

which is not good. Whenever you change the signature of Counter in the future, e.g. to return more functions, you’ll need to find and update every line like this.

As others have pointed out, the closure approach is also harder to work with, less readable, and less efficient. So overall, I don’t really see any advantages of using it, and I would go with either an immutable struct just like in @dpsanders post above (creating a new instance for each modification) if you want the advantages of immutability, or simply a mutable struct.

I’m curious though where you got the idea, have you worked with languages/frameworks where this particular closure-based pattern is preferred? Anything you can link to?


#17

haha, we’re all “old” guys in this forum I guess. I used to code in C, C++, Java, R…

as @WschW has mentioned, traditional OO supports “private” fields, and methods “belong” to a “class”. The type system in julia is a new concept for me… I still feel “dangerous” using the mutable struct way…


#18

this does not work well if the type is composite. For example:

struct Composite
    smallest::Int64
    largest::Int64
    values::Vector{Int64}

    function Composite(x::Vector{Int64})
        new(minimum(x), maximum(x), x)
    end
end

function newelement(c::Composite, x::Int64)::Composite
    return Composite([c.values; x])
end

julia> a = Composite([2, 3, 5])
Composite(2, 5, [2, 3, 5])

julia> b = newelement(a, 1)
Composite(1, 5, [2, 3, 5, 1])

as the composite type contains a non-scalar field (i.e., values), every new Instantiation of this immutable type would result in memory allocation


#19

… which may or may not be the bottleneck in particular code, so worrying about it prematurely does not always pay off.

Also, if you happen to be accumulating an ex ante unknown number of elements, you cannot always avoid some memory allocation.

The best approach is to design a small interface, which provides a layer over the actual mechanism. You can benchmark and optimize the latter, and change it without affecting the rest of the code.


#20

I think, it’s worth discussing and thinking deeply on the tradeoffs between the flexibility of mutable struct and the benefits of traditional encapsulation.