TLDR: musings on how redefining structs cannot be as convenient as we’d like, nothing really important so feel free to ignore this thread
Overoptimistic expectations
We enjoy the interactivity and incremental code modifications that a dynamic language offers, and Julia’s multimethods achieve a particular kind of intermodule composability, if we sensibly avoid type piracy. But while we can add or replace methods in a function and invalidate its callers to adjust, we cannot change anything within a struct definition. There have been other discussions about this “missing feature”, and they generally expect an analog to method invalidation: change the struct, and the callers adjust accordingly. After all, it’s a dynamic language, you can redefine classes in Python, so why not structs in Julia?
It’s much more complicated than method invalidation
If we expect an analog to method invalidation, then we should compare structs and functions more carefully. First we should recognize that they’re not distinct concepts. A function has a singleton subtype of Function
, a const
name, and no fields. Like a function, a type constructor is a multimethod and also causes invalidations of callers. However, unlike independent methods, a struct
’s parameters, supertype, fields, and inner constructors are rooted in the type and are usable by any method. It’s not possible to make multiple versions of these like multimethods, so a struct
block necessarily attempts to reassign the const
name of the type. Currently, it’s not possible to replace a struct like that, but it’s also not possible (or even desired) to replace a function, either. When we write function
blocks for an existing function, we do not reassign that const
name, we just add or replace one of multiple methods.
But let’s assume that we’re trying to make replacement of structs possible. You can invalidate a caller foo() = MyType(1, "a")
, and that does happen already. However, types don’t just show up in calls. A big one is argument annotations, like bar(::MyType) = 0
. If the annotated types would be replaced, these methods would be essentially replaced, and the method dispatch tree would be modified. Commenters feel free to correct me, but this could be tracked with some version of backedges, though I expect much more overhead as I believe type annotations outnumber function calls. Bear in mind, automatic inheritance adjustment does not occur in Python: the obsoleted class remains in superclass lists, so subclasses must be reevaluated (though it’s easy to find them).
The thornier issue is existing instances in the global scope, whether of the obsoleted type or from a function call that relied on the obsoleted type on some level. Tracking and reevaluating these can become very expensive (const ohgodwhy = takes5minutes(args...)
). Unlike recompilation of invalidated methods, reevaluation of expressions cannot be lazy and incremental much because different orders of evaluations can result in wildly different behavior.
Reevaluation causes artifacts, replacing instances is questionable
The straightforward way to replace a struct is to restart the REPL and evaluate everything from scratch, which can take an undesirably long time to load necessary dependencies and recompile methods. However, redefinitions alone cannot replicate the behavior of a full restart, and this is true even for method redefinitions e.g. baz() = 1; a = baz(); baz() = 2; a # still 1
. That raises the possibility of using automatic reevaluation to have the active code fully adapt; baz() = 2
triggers an implicit a = baz()
. Problem is if we replaced the call signature, then past code would throw MethodError
s if reevaluated automatically after redefinition. In most cases, there won’t be a sensible way to 1-to-1 replace a call signature in source code, that must be done manually, and only after that is reevaluation possibly useful. This can get much more complicated than global instances, with cross-module influences and conditional evaluation of methods.
Even assuming the call signature is not changed, one suggestion (that I also have made) has been to only reevaluate in the module the struct belongs to, the idea being it’s isolated, but modules are rarely fully isolated from one another. Other modules will also need to be reevaluated to adjust behavior, and it’s not as “simple” as tracking functions extended on the type and modules importing such functions or the type. For an example, let’s say my working module uses custom units types in a method to compute the speed of light const c = computec()
, which gets exported very liberally. Then I change the types to different units, which the value of c
adjusts to. However, none of the modules that imported c
imported my types, so they happily use the obsolete value. You might then suggest to track all modules that import anything from my working module, but that doesn’t cover modules that import different values, like a light-year, computed from c
outside my working module. If we play it safe and reevaluate in order every module our working module can reach via imports, it could easily save very little time. If we extended any function in Base
on the redefined type, which is imported in every module except baremodule
s, we might as well restart the REPL to save on tracking overhead.
Let’s assume that it’s just unfeasible to find and evaluate a subset of past code that both replicates a restart and saves a lot of time, and we choose the latter: just replace the type, its global instances, and the live methods using them; every other necessary change, like methods that call the type constructor, must be done manually. Then we have to also decide if that retroactivity is the sort of dynamic feature we want. Take method redefinitions again baz() = 1; a = baz(); baz() = 2; b = baz(); (a, b)
; we expect to see that change in (1,2)
. (FYI avoid method redefinitions in source code, those are unnecessary and break incremental compilation.)
Strategies and package highlights
This doesn’t mean we need to restart the REPL every time. Replacing a struct only becomes a big reevaluation problem if you had built many methods and modules on top of it.
- When you are still experimenting, you can make throwaway structs and test a few interface methods on them, and try to reach a decision on the structure and supertyping before extending the functionality.
- If you carefully make a supertype interface and make many methods rely on that supertype instead, a new subtype struct and its interface methods can smoothly integrate into the supertype’s methods (this doesn’t handle global instances though).
- If you haven’t exported the type or extended other modules’ functions with it, you can replace the whole module without affecting other modules at all. Julia v1.9 makes this smoother by letting you change the module you interact with in the REPL. Packages don’t have to be reloaded, just reimported. Other modules’ functions extended with obsolete types should not get in the way if you never use the obsolete type again, it’s just that those obsolete methods will show up in reflection, which can be confusing.
People only desired redefinable structs for experimenting, so it’s reasonable to keep the experiments small and isolated so the widespread changes are feasible without restarting everything. There are a couple packages for this context; bear in mind that neither package does retroactive reevaluation.
-
RedefStructs.jl is not kept up to date with Julia, but the macro
@redef
renames astruct
block to make a hidden struct assigned to a non-const
global name you provided. It’s a bit more convenient version of throwaway structs. -
ProtoStructs.jl is a bit more updated, and the macro
@proto
changes astruct
block to a different struct parameterized by and containing the fields as aNamedTuple
orDict{Symbol,Any}
, depending on if the struct was immutable or mutable. Another@proto
block just changes the active parameter of the type constructor. Unlike RedefStructs.jl, this strategy does not accommodate supertyping, and it appears the type parameters must be entirely inferable from field values.