Struct with read-only and write-only properties

Suppose I have the following mutable struct

@kwdef mutable struct Rectangle{T}
  x::T = 0
  y::T = 0
  function Rectangle(args...)
    return new{promote_type(typeof.(args)...)}(args...)
  end
end

I would like for this struct to have two additional properties, 1) area which is read-only, and 2) square which is write-only and sets both x and y:

function Base.getproperty(r::Rectangle, key::Symbol)
  if key == :area
    return r.x*r.y
  elseif key == :square
    error("Property :square is write-only for Rectangle")
  else
    return getfield(r, key)
  end
end

function Base.setproperty!(r::Rectangle{T}, key::Symbol, val) where {T}
  if key == :area
    error("Property :area is read-only for Rectangle")
  elseif key == :square
    r.x = val
    r.y = val
    return val
  else
    return setfield!(r, key, T(val))
  end
end

Even though this is a mutable struct, I have a general framework which only creates new promoted structs if the type of a field is set to a value which requires type promotion. This makes use of the super powerful Accessors.jl . E.g.

r = Rectangle(1.0, 2.0)
r.x = 1im # error because r isa Rectangle{Float64}

using Accessors
@reset r.x = 1im # works!

The problem is if a user tries to (re)set a the write-only field including promotion:

r = Rectangle(3.0,4.0)
r.square = 5.0 # fine
r.x == r.y == 5.0 # true

@reset r.square = 2.0im
# ERROR: ArgumentError: Failed to assign fields (:square,) to object with fields (:x, :y).

With this error, and with some reading of ConstructionBase.jl documentation, I currently have two questions:

  1. How should I override Base.propertynames for the case where some properties are write-only and some read-only?
  2. The documentation suggests that I should override ConstructionBase.setproperties (and ConstructionBase.getproperties ?) however in the documentation for these functions I see the requirement:
    3. setproperties is defined in relation to getproperties so that:
       obj == setproperties(obj, getproperties(obj))

How do I handle this when some properties are write-only and some read-only?

1 Like

I don’t think there is a lot of precedent for “write-only” properties, are you sure that’s what you need?

If you really need them, overloading propertynames() to return rw + r properties and setproperties() to support rw + w properties should just work correctly. I’m pretty sure the ConstructionBase docs (including the requirement you cite) didn’t anticipate writeonly properties at all :slight_smile:

3 Likes

Write-only memory is usually a joke because there’s no point in writing data that will never be read, but not when considering permissions. For example, you might let participants enter their names and addresses into a raffle, but it’s obviously bad to let any participant see the full list. In a programming language, the relevant concept is access modifiers.

There are plenty of OOP examples of public properties with only (dot syntax) setters that change the value of a private property so that only the class’s methods can use that private property freely. Granted, those examples are also often explicitly qualified to have very little use because it’s fairly difficult to make a setter’s inputs that’s unable to replicate what a getter does. For example, I don’t need r.square to get a value r.square = 5.0 I just set. Also, Julia only has modules for true encapsulation and doesn’t have formal access modifiers, so those OOP examples don’t translate well. Instead, public-ness regarding types is specified by API and leans more to methods than properties.

It’s also worth considering whether something is a property. All rectangles have an area (and even then Julia would normally use an area(::Rectangle) method), but rectangles don’t have squares (which is why a getter doesn’t make sense), rather some rectangles are squares. Making a square rectangle is as simple as Rectangle(x) = Rectangle(x, x) or r.x = r.y = z, no need to try to justify a setter.

Weirdly it almost does with function lenses. Strictly speaking, it’s properties of an alternate type representing an equal value (in the linked example, a 2D Cartesian Point vs a polar NamedTuple), but the @set syntax resembles setting a write-only property.

I will strongly second the recommendation to make a function area(::Rectangle) rather than overriding getproperty to support r.area. In Julia especially, an accessor function is much more powerful, simple, flexible, extensible, and reusable than getproperty/setproperty!.

I only occasionally use object.field outside of defining accessors (and never in a function where object is not specifically dispatched as an input argument). In my early days of Julia, I would frequently use object.field in code but sometimes (surprisingly often, actually) would have to make tedious revisions as my project evolved. Accessor functions have been much easier to maintain and expand, which is why I use and recommend them.

I would handle your example like this:

area(r::Rectangle) = r.x * r.y

function square!(r::Rectangle, val)
  r.x = r.y = val
  return r # or `return val` or `return nothing` or whatever you want
end

Sidenote: I seldom use mutable struct (<5% of the objects I create). A (immutable) struct is usually easier to work with and tends to perform better (even when “changing” it means you must rebuild the whole thing with some fields changed rather than simply changing them – the compiler is pretty good at this). I would definitely make an object like the example’s Rectangle immutable in almost every circumstance.

2 Likes

Function lenses are great and everything, but you are linking a different package here – which is higher level than ConstructionBase :slight_smile:
Basically, the idea of Accessors.jl is that @set f(x) = newval should work for every x and f wherever it makes sense. It’s a very powerful and widely applicable concept indeed!

Thanks to everyone for your detailed replies. I certainly agree that this approach is not the most Julian on the surface. But let me explain the use case and I would greatly appreciate input.

I have one higher level struct which may have an enormous amount of different properties. So the first idea is to do something like:

mutable struct MyStruct
  x
  y
  lorem
  ipsum
  dolor 
  ... # tons of possibilities
end

The problem with this approach is that (1) most instances of this structure will not need most of these properties, (2) the properties can have different types so every get is type unstable even if some properties are very related, and (3) it is not extensible if I or another user later wants to add another property, I have to change the entire struct.

To solve/improve these three issues, I instead have done the following:

  1. Group related properties into substructures (e.g. Rectangle)
  2. MyStruct has one field: a dictionary with the key being the type of the substructure, corresponding to a value that is the substructure
  3. override Base.getproperty(::MyStruct, ...) and Base.setproperty!(::MyStruct, ...) to identify the substructure group which the property symbol belongs to, and call getproperty/setproperty! on that corresponding substructure.

This solution makes it so that I can get chunks of related properties from the MyStruct with only one type instability. e.g., Now mystruct.Rectangle (which in my framework is equal to mystruct.substructure_dict[Rectangle]) gives me the corresponding Rectangle{T} (type unstable step), but at least from there I can now get both x and a y type-stably. In the first flattened implementation, mystruct.x and mystruct.y are separately two type unstable gets rather than just one. I can also always add more new substructures later. E.g. maybe I want to add a Dog:

@kwdef mutable struct Dog{T}
   mass::T    = 0
   height::T = 0
end

But users can also do: mystruct.x = 5.0, or mystruct.area and still get the behavior as if it were the first idea above. And if users do mystruct.mass = 187, then I will create a new Dog and put it in the struct on the fly. It’s like a quasi-flattened structure optimized for flexibility and performance. And if you do something like mystruct.x = 5.0im # promotion, then I detect this and use Accessors.jl to replace the current substructure with a promoted one. It is fully polymorphic and gives excellent performance in optimization loops, in fact often fully equal to an alternative bits representation. I therefore am quite happy with this implementation and struggle to find a better way (your input greatly appreciated!).

For this reason, my end users will expect to just do mystruct.x for both setting and getting all information from this struct, rather than doing mystruct.x for some information and specialgetter(mystruct) and specialsetter!(mystruct, val) for others.

The full implementation is here if you are curious: GitHub - bmad-sim/Beamlines.jl: Fast, flexible, and polymorphic/differentiable beamlines

I’m afraid I don’t precisely follow what you’re describing and the linked repo was too large for me to find further clarification. But I can assure you that getters/setters are no less powerful than getproperty/setproperty!. I’ll admit that one additional reason I don’t modify properties is that it’s just such a pain to do.


But I have some other concerns about (my flawed understanding of) your design. Maybe not all of these concerns are relevant to your intended uses.

Fundamentally, I don’t see the appeal of making mystruct.x magically refer to mystruct.substructure_dict[:Rectangle].x. The flattened structure does not seem more flexible, convenient, or performant to me (and you already decided it was better to step back one level from it by only making it appear flat). What if I have two substructures with the x property? How does it decide which to read/write? I think Rectangle(mystruct).x is much more clear, which you can enable by simply defining

Rectangle(a::MyStruct) = a.substructure_dict[Rectangle]
# personally, I would use `Symbol`s for keys rather than `DataType`s

Similarly, while you can define

area(a::MyStruct) = area(Rectangle(a))

I don’t think area(mystruct) is more clear than area(Rectangle(mystruct)). And again, what it two substructures have an area attribute?

Your current key scheme offers no way to put two Rectangles into a MyStruct. But that seems to not be something you need, so never mind.

Honestly, all the code to introspect every element of substructure_dict to find where it can hopefully read/write something (which might be ambiguous!) seems like much more effort and much more fragile than simply making getters for those elements. All the magic that the introspection introduces seems unnecessary and, while it might save the user a few keystrokes when the code is written, will cause extensive head-scratching when that code is read and maybe bugs when it’s run (if there are ambiguities).

Just like if the structure were actually flattened, I cannot have two fields with the same name. So at the level of MyStruct properties with the same name can only point to one substructure. By design ambiguities cannot happen.

In my application this is not allowed.

This is not the implementation, it is much simpler. Developers of the substructures can add to a dictionary its fields/properties which they would like to be accessible at the MyStruct level. Base.getproperty of MyStruct goes to that dict with the symbol it receives. There is no introspection of every element (this of course would be very slow…).

My end users are not career programmers. They are focused on physics. As such, one single interface for getting/setting any information from the substructures is strongly preferred. If I have to explain to them that, “For getting/setting these certain properties, you must use these functions, and for getting/settings these other properties, you can use the usual dot-syntax”, then that may be confusing…

Furthermore in my implementation, the performance of doing mystruct.substructure_dict[Rectangle].x is very similar to doing mystruct.x. In my Beamlines.jl package for example:

julia> using Beamlines, BenchmarkTools

julia> ele = LineElement();

julia> @btime $(ele).L
  47.654 ns (0 allocations: 0 bytes)
0.0f0

julia> @btime $(ele).pdict[UniversalParams].L
  34.000 ns (0 allocations: 0 bytes)
0.0f0

For my use case, this get time (order of 10 ns) is much, much smaller than the simulation time (microseconds - seconds), and so the difference between 48 and 34 ns is acceptable with that considered.