Thoughts and tempering expectations on redefining structs

I am trying to make the point that what is “constant” in Julia is not written in stone; current behavior could be modified without any major problems.

Thus,

  1. one could redefine struct too, as long as all code relying on it is updated accordingly,

  2. instances do not need to be converted, as they just belong to a different type (that used to have the same name).

I see, it’d be clearer if the _my_constant() was noted to be a demonstration for deterministic immutable values. On the actual topic, I disagree on consts being allowed to change and invalidate methods. Typed globals can be reassigned without needing to recompile methods. Right now I’m of 2 minds:

  1. Python-like route: use a non-const global alias to the structs, don’t edit any method annotations or existing instances
  2. Lisp-like route (pending more education, this is new to me): Special case of const reassignment but must replace the obsoleted struct entirely, automatically change method annotations and dispatch, automatically change existing instances with a probably less-than-ideal method but it beats(?) retroactive reevaluation, manually change call sites in methods as usual.

Personally, I find the Python route very annoying for interactive use as I also need to find and recreate the relevant instances in order to test the new class.
Common Lisp (or Smalltalk) are much nicer in this respect coming from the tradition of live images, i.e., the runtime holding all your code, objects etc and always being up to date (in some Smalltalk systems saving code in source files is neither easy nor recommended). Thus, coding means adding stuff to the living image and its reflective abilities allow to basically adapt every aspect of it. Here is a nice example from the Common Lisp HyperSpec showing how to change the representation of a class for 2D points from a Cartesian to a polar implementation without breaking any existing objects, functions etc: Link

3 Likes

The syntax is really hard for me to understand, but it looks like it only demonstrates a possible change in instances, which I’ve read about in other sources. Since Lisp has generic multimethods like Julia, do you know what happens in the following when a class is redefined?

  1. Do method annotations swap out the obsolete class for the new class, is the dispatch tree changed?

  2. The instance editing is described as swapping out the obsoleted slots (fields I think) for new slots, and the user can specify how the new slots are computed given the old slots. However, what is done in practice when there’s no common sense way to derive the new slots from the old slots, like if the new slots are intended to be computed from additional data not present in the obsoleted instance?

  3. How are LISP class instances structured to allow removal and addition of slots, has it been implemented for classes that are packed like structs before? I know that Lisp has classes and structs, and from what I could read, classes are not as efficient (dynamic dispatch, can’t be stack allocated, method calls can’t be inlined). If this is necessarily the case, then it is a nonstarter.

Not really part of the questions, but IMO this doesn’t seem like the dynamic I prefer because it replaces past instances (so a loop that redefines a type and pushes an instance to a vector will end up with a vector holding 1 newest type). But my hunch is that a replacement like this doesn’t run into evaluation order artifacts that reevaluation of a subset of past code would, so it seems like a worthy feature for now.

Certainly don’t know all details of the Common Lisp object system (CLOS) and its meta object protocol (MOP), but will try to answer to the best of my knowledge:

Yes, as the class has changed all references to it are now to the new class. This includes method specializations as well.

The meta-object protocol has several methods that can be extended by the user to customize the behaviour. The default method will drop obsolete slots – the CLOS name for fields indeed – and populate new ones from their initial value – if none was defined in the new class, the slot will stay unbound.
If the new values cannot be computed there are several options: First, just leave them unbound, i.e., without a value as an undefined variable, and set them later on each instance if needed, e.g., using an error handler, or, secondly, define a method for update-instance-for-different-class or update-instance-for-redefined-class possible using external/global state to pass in additional information – CL has dynamic variables after all.

Correct, classes are less efficient than structs in Common Lisp. Reasons are that methods are dynamically dispatched at runtime (CL does not devirtualize method calls like Julia, CLOS usually does cache its dispatch table though) and specific methods from the MOP allow the user to hook into this process. Don’t think that all of that would be required to support replacement, but it certainly helps. In contrast to Julia, CL tends to lean towards dynamicism over performance when deciding on its trade-offs.

3 Likes

Seems tricky to do, at least in Julia, not sure in Lisp. The global instances that need replacing could be scattered across modules and constructed from various other global variables or even temporary data that no longer exists. Even assuming that there will always be 1 global variable holding all the necessary data, the update method will need to be defined in that module to use that global (lexical scoping, @which shows which module), so the method has to be duplicated (with different signatures) in every affected module. However, we would like to define 1 method in the same module as the replaced type to work in all modules, so global state seems unreliable. Undefined values for isbitstypes are unpredictable, so I think it might be better if our version has an argument for extra information that is used to put in default values. It cannot behave the same as if we had edited the type and call sites in the source code then restarted the REPL (for example, a different type from the start could change global values of other types or which methods were conditionally evaluated), but this could just be accepted as an interactive limitation, just like how we accept that redefining a method doesn’t undo its past calls’ effects on the program.

Fingers crossed.

Well, it does work in Lisp or Smalltalk when using an image based workflow, i.e., just snapshot your whole runtime state – the image – and simply restart it when continuing coding – at exactly the point where you left off. In Common Lisp this is already less common than in Smalltalk and most of its code is loaded from source files instead – as in most other languages. In this case, full reflectivity is still nice for interactive use, but not strictly required.
Compared to Python, the Julia REPL is already quite good. In particular, redefining structs is much less needed than redefining classes as methods are not defined within classes and can already be redefined at will. Further, when wrapping structs into modules they can easily be replaced, leaving existing instances kind of obsolete though, i.e., similar to Python were they still refer to the old class. Unfortunately, methods which are not defined in the same module and therefore reloaded as well, might still dispatch on the obsolete type and not work as expected. In any case, my current workflow is to put all my definitions into a scratch module and simply reloading it while experimenting. Requires a bit more typing in the REPL – as I need to include the module name as well – but otherwise works well enough for me. Maybe some macrology, e.g., interacting nicely with Pluto or Revise, could make that even nicer though …

1 Like

You can assign or reassign functions to Python class attributes after the class definition.

Revise.revise(mod::Module) reevaluates every definition in a given module, if that could save typing. It doesn’t reevaluate the module itself, and it’s really just useful for changes to macros and methods.

Feels sort of like Pluto.

Let me tell you a story of my grandfather and grandmother:

Grandmother: “I’d like you to put a big window in that wall so we can get some more light in here!”

Grandfather: “That’s a load-bearing wall!”

Grandmother: “Why are you giving me information I don’t care about?”

The point: users don’t care what hardship you have to endure to deliver a desired feature.

Conclusion: suck it up.

Moral of the story: grandfather breaks the load-bearing wall to put the window, everyone dies while sleeping when the house falls over them.

2 Likes

Alternative ending: Grandpa overspends on a contractor to completely overhaul the home’s structure. Family goes bankrupt for a window.

Or happier ending: The two life partners make reasonable compromises and coordinate together because they are both invested in the long term success of the family :man_shrugging:

2 Likes

Yes, had not thought about that.
My usual workflow uses a REPL alongside one or more source files: Write some code in source file → Evaluate region in REPL → Test things in REPL → Go back to source and change it → Evaluate region in REPL → … (repeat forever)
In this case, I would simply change and redefine the class definition instead of keeping the old method in the class and overwriting it, i.e., via Class.mymethod = some_new_function.

In Julia, I usually try the same workflow and it works nicely when coding up methods. You’re right though that neither VSCode or Revise allow to redefine structs. Thus, my fallback is often to just include the whole file/module again after each change and then pick up from there in the REPL. This working around the problem of this thread though.

Guess Pluto can feel similar, it’s experience is a bit less immersive though. Just for fun, you might want to try Squeak/Smalltalk to get a feel for image-based development. Just a simple example: You don’t like the background colour of your windows? Just find out which object it is and change its colour, either locally directly in the property inspector or with a small script for all instances of that class!

3 Likes

That’s probably for the better. const variables, including the implicitly const ones like module, function, or struct blocks’ names, play into some important optimizations that are not easily reversible. You can reassign const global variables and replace modules, and everything else still uses the obsolete instances, which is why the reassignment prints a warning. If I have to rerun code in a module, I try not to reevaluate the module block itself, I prefer to revise(affected_module) or choose code to @eval affected_module rerunExpr / Base.include(affected_module, "rerunstuff.jl").

The thing is, annotations and calls don’t need to be done with const variables. The Revise.jl docs show off a trick of using a non-const alias that you assign to experimental structs. The calls use a non-const global and won’t be efficient, but they’ll adapt no problem. The annotations bake in the type during definition so the method has to be reevaluated. The Revise.jl example seems to claim that Revise handles that, but I haven’t been able to make that work well so I use revise and Base.include.

1 Like

I’m sure that’s naive, but even without backedges, couldn’t you iterate through the entire method table and invalidate all methodinstance (? maybe I’m using the wrong word) that accept an X or that are inferred to return an X?

I think redefining structs is in a different category, most likely in the bucket “we don’t want to go there.”

:100: I can definitely understand that!

1 Like

Replacing the methods annotated with an obsolete type is a more pressing issue because they could interfere with method dispatch (EDIT: actually is that true for concrete types, it’s definitely true for abstract types and I had been including that mentally) even if you never use an instance of the obsolete type again, and it does also seem possible to me that your suggestion could do that. I’m curious how CLOS replaces instances and specialized multimethods along with a class, maybe that can be a blueprint.

Please take this with a grain of salt, but skimming through the sources of Steel Banks Common Lisp, it seems to work as follows:

  • Classes are itself objects with a field direct-slots containing a list of slot-definition objects.
  • On changing the class, the new slots are collected and the new class is then copied into the memory of the old one, i.e., effectively changing all existing references to point to the new class.

This works as the memory layout of the class itself is not changed. I.e., Lisp adheres to the fundamental theorem of software engineering that " Any problem in computer science can be solved with another level of indirection (except for the problem of too many levels of indirection)" by David Wheeler.
Not sure yet, how the instances are actually moved to the new layout … but the above already explains why method dispatch is not effected – as the class is not visibly changed regarding the dispatch machinery.

To be honest, what I really want is to rename the previous package module to something else, and then load the package again into the now vacant name(space).

1 Like

I’ve been reading about Smalltalk since reading this thread and just downloaded it to start experimenting with it. They handle this by tracing the class origin of all instances, so if you redefine something, it has a graph of everything that needs to be updated. And since Smalltalk opens a debugger on errors (instead of throwing exceptions), you can write methods to resolve any problems on update. (Very cool!)

My understanding is that Pluto achieves something like this by having an evaluation graph (is this the right phrase?). Every time a type related thing happens, the Workspace module name gets incremented, and all variables that were assigned using a certain Type name need to be re-evaluated (so they error out until you fix things)… or that’s my outsider understanding… @fonsp please feel free to tell me how wrong I am :sweat_smile: Pluto’s internals never cease to amaze!

What’s truly crazy to me about Pluto is that it tracks that a will need to be recalculated if X changes in the following example:

# cell 1
struct X
	a
	b
end
# cell 2
function make_X()
	y = X(1,2)
	return y.a
end
#cell 3
a = make_X()

This has me thinking that it would be cool if Julia could have a “Smalltalk mode” where every module is treated like a Pluto Workspace so when you update a Type, the runtime would start going through everything that needs updating and asks you how you want to handle broken cases.

Then, when you are done experimenting, you could run things with Smalltalk mode off, and it wouldn’t have to track so much metadata.

3 Likes