Redefining structs without restart?

Well, yes and no. Again, this works fine for scripts and without using Revise, by exploiting the fact that in the REPL you are allowed to replace modules (you still get a reprimand WARNING: replacing module Data., but who cares?). This however doesn’t help much when developing packages imported with Revise.

My “module Data” usecase was about not using Revise.

Sorry maybe I expressed myself badly. What I meant is, if you have

g(x) = ...
f(x) = g(x)

specialize f and then redefine g, then f must detect that the specialization it already has is old and must be updated. I think this is something that wasn’t present from the beginning and got implemented around 0.6. So there is already a mechanism to detect when a specialization has to be updated.

Now, regarding structures, I imagine that every structure has a representation somewhere in the internals of Julia. When I do struct S ... end, something is allocated somewhere to keep track of this structure. What I’m suggesting is that if I execute struct S ... end again, then a new structure is created, which happens to have the same name (but is still a different entity internally). This is similar to what Python does:

class C:
    pass
print(id(C))
class C:
    pass
print(id(C))

prints two different identities, because two classes with the same name are created, and the latter shadows the former in the current namespace (but the old still exists).

Then, functions could respond with the specializations they already have to arguments of the old type (if you happen to still have some around), and would produce a fresh specialization if you pass objects of the new type. Internally the two types are distinguishable, you just lose a “handle” to refer to the old type.

Probably for an in-depth discussion on this you should refer to:

https://github.com/timholy/Revise.jl/issues/18

The take-away from there, it seems, is that there might be a solution. We just need more timholys in the community.

4 Likes

I’ll just be so bold as to quote myself (though I’m fairly certain I’ve read something along those lines from other people as well :thinking:)

To expand on that (and not make this shameless self promotion):

Julia has eval (and its macro companion, @eval), which evaluates an Expression at runtime in global scope. Now imagine the following:

struct IntWrapper
   a::Int
end

function increment(a::IntWrapper)
    a.a= + 1
end

function f()
    a = IntWrapper(1)

    # no wait, actually, I want IntWrapper to wrap Floats as well
    @eval struct IntWrapper
        a::Union{Int,Float64}
    end

    b = IntWrapper(5.0) # wait, which Constructor will be called now? And which type will `b.a` have?

   increment(a) # wait, do we print here as well..?
   increment(b)
end

Julia is a compiled language, not interpreted. This means that, when I call f(), the function f as a whole is compiled to machine code. But I have eval code in there that changes the behaviour of the code afterwards while staying in the same function! That’s impossible, so it’s disallowed.

There are more details here, but that’s the gist of it. You could of course change all function calls to make a dynamic lookup in case the definition of the types or the function has changed… but then you lose all benefit of compiling functions in the first place and you’re back to interpreted speeds (which, I’m sure, you also don’t want).

It’s easy to say “why not just do x” and of course you could do x, but that often comes at the cost of things we really want to have, like fast, native, compiled code that can run everywhere. If you insert dynamic lookups at any point that you call a function, writing e.g. GPU kernels is right out (without resorting to a second compiler). There are a lot of design decisions like this, and often there sadly isn’t a straightforward, compact answer why it is that way.


In any case, there have been A LOT of topics on this forum about this and similar things already - you’re not the first to encounter them and won’t be the last. There’s also a lot of issues on the issue tracker, so please do a little more research before pointing to feature X from Y and ask why julia doesn’t do that and that it’ll be just so easy to do.

2 Likes

Would this problem still exist if there would be no eval and @eval command?
(I’m not proposing to remove these commands, I just want to understand how Julia works.)

You’re welcome! Thank you for reading it :slight_smile:

If there’s anything that’s confusing or seems unintuitive to you, please don’t hesitate to open a new topic and ask about what’s weird to you.

1 Like

This section of the manual also provides some insight on the difficulties of what you propose:

The types Bool , Int8 and UInt8 all have identical representations: they are eight-bit chunks of memory. Since Julia’s type system is nominative, however, they are not interchangeable despite having identical structure. A fundamental difference between them is that they have different supertypes: Bool 's direct supertype is Integer , Int8 's is Signed , and UInt8 's is Unsigned . All other differences between Bool , Int8 , and UInt8 are matters of behavior – the way functions are defined to act when given objects of these types as arguments. This is why a nominative type system is necessary: if structure determined type, which in turn dictates behavior, then it would be impossible to make Bool behave any differently than Int8 or UInt8 .

5 Likes

@Sukera
Nothing in your answer is a good argument of why it shouldn’t be possible to redefine structures. The example you give with @eval can be flipped around and used to argue that redefining functions is bad too. Consider

h() = "old"
g() = println(h())
function f()
    g()
    @eval h() = "new"
    g()
end
f()

What should it print? "old" and "old", or "old" and "new"? It turns out that the answer is the former, so we could decide that your example should have a similar semantic and the redefinition of IntWrapper comes into effect only after the execution of f, not during. This however is not an argument against redefining structures.

Moreover, consider the following code too:

function foo end

module M
  struct S end
  Main.foo(::S) = print("old")
end

s1 = M.S()

module M
  struct S
    x::Int
  end
  Main.foo(s::S) = print("new $(s.x)")
end

s2 = M.S(42)

foo(s1) # prints "old"
foo(s2) # prints "new 42"

Here, we can play the trick of redefining the module M, so we end up with two indipendent types both called M.S. The function foo is still able to distinguish them and specializes accordingly. Where is the problem?

What I’m suggesting is an extension of the current semantic of the language. I propose that we should be able to do the same as in the last example, without putting S inside a module. The following code

function foo end

struct S end
foo(::S) = print("old")

s1 = S()

struct S
  x::Int
end
foo(s::S) = print("new $(s.x)")

s2 = S(42)

foo(s1) # prints "old"
foo(s2) # prints "new 42"

should have the same behavior. Namely, two distinct types S are created, both named Main.S, and the function foo specializes separately for the two types.

This doesn’t look at all impossible to do to me. When you “redefine” a structure, in reality you actually define a new structure which happens to have the same name. The old structure still exists, you simply lose the possibility to refer to it by the same name in the current namespace, because that name now refers to the new structure.


I don’t like much the implication of what you are trying to say here. I’m not being lazy and just complaining “Oh Julia is so crappy compared to COBOL!”. I’m exploring outside the current semantic of the language and proposing options for possible extensions.

1 Like

Yes, I understand what a nominative type system is. But could you elaborate on how this implies that redefining structures should be impossible?

I am certainly not the wright person to elaborate on that. I just want to point out that the “solution” needs to deal with the names of the structs somehow, otherwise to what an “old” method would be specialized to? Seems that the name of the type is what defines the specialization.

Also it does not seem to be impossible, from the issue thread that I linked above, it just seems that there is no one available with the time and knowledge to do that exactly now.

1 Like

I agree with you that it is not trivial to implement, nor is it completely clear and uncontroversial what the semantic should be. I simply believe that we should discuss what we expect the language to do, and just saying “Oh it’s impossible because @eval is not a very constructive way of exploring the landscape in my opinion.
As a side note, in the REPL we are already allowed to redefine both modules and constants, and the behavior is not that far off from what I would expect from redefining structures:

const c = 0
f() = println(c)
f() # prints 0
c = 1
f() # still prints 0
f() = println(c) # forces an update
f() # prints 1
1 Like

What would you expect from redefining structures? That means redefining types, and what should happen to functions that already depend on that old type? Should they all be recompiled?

3 Likes

Ok, let me try to be very explicit with the words to avoid ambiguity.

When you say “redefining structures”, I interpret it as executing twice some code like struct S ... end. I do not interpret it as modifying the internal representation in Julia of an already existing type.

One possible way to deal with such event could be to define a new type called S, independent from the previous type also called S. In the namespace of the current module, the name S would now start referring to the new type instead of the old type. If you build an object of this new type and feed it to a function that was already specialized for the old type, then yes of course the function would have to specialize again! It is a new type after all.

For instance, in the hypothetical code

abstract type A end
f(::A) = 42
struct S <: A end
s1 = S()
f(s1) # returns 42
struct S <: A; x::Int end
s2 = S(0)
f(s2) # returns 42

s1 and s2 would be objects of different types, the old and the new S, and the function f should specialize for both of them.

Notice that we can already achieve something similar by wrapping the structures in a module and redefining the module:

abstract type A end
f(::A) = 42
module M struct S <: Main.A end end
s1 = M.S()
f(s1) # returns 42
module M struct S <: Main.A; x::Int end end
s2 = M.S(0)
f(s2) # returns 42

If you execute methods(f).ms[1].specializations, you see that f has two apparently identical specializations: one for the old M.S and one for the new M.S.

My expectation is just that this should work the same without wrapping in a module. I don’t think it’s realistic to expect something much different in this case.


Now, let’s move on to something slightly more controversial. Let’s say that instead of defining f for an abstract type A, we have instead a definition for f(::S):

struct S end
f(::S) = 42

If we redefine S, should this method be applicable for the new S? If we perform the module trick as before

module M struct S end end
f(::M.S) = 42
s1 = M.S()
f(s1) # returns 42
module M struct S x::Int end end
s2 = M.S(0)
f(s2) # fails: no method matching f(::Main.M.S), closest candidate is f(::Main.M.S)...

we see that currently it fails with a cryptic error message. There is nothing inherently wrong with this behavior: the definition of f was intended for the old type M.S, and the new homonym type M.S is a different type.

However, from a usability perspective, one could argue that it is possible to expect the following: when f receives an argument of the new type M.S, it detects that it has a method applicable for the old M.S that was shadowed and specializes again that method for the new type. I’m not saying that this is the right thing to expect, but there is nothing insane or incoherent in expecting this behavior.


In conclusion, I think the behavior of the following code

struct S end
f(::S) = 42
struct S x::Int end
f(S(0))

is open to debate, but there are at least two reasonable behavior that one can expect: either MethodError because it is a new type, or specialize again because the new type is a redefinition of an accepted type (a sort of “retroactive dispatch”).

4 Likes

First off, sorry you took my comment this way, I certainly didn’t mean to imply that you’re lazy. I don’t take your comments so far as “just complaining”, rather as a little uninformed about why these long-known issues have not been solved (and likely won’t be in the foreseeable future).

Note that you’ve misunderstood the semantics of const - it doesn’t mean that c will never change value, it means that c will never change type. It’s also not a guarantee the compiler gives you, it’s a promise you make to the compiler about not changing value or type, so that it may optimize code that depends on that type:

julia> const c = 1
1

julia> c = 1.0
ERROR: invalid redefinition of constant c
Stacktrace:
 [1] top-level scope at REPL[2]:1

This is consistent with your observation that you have to “redefine” f. Since the binding was marked as const, the compiler inlined the value into the compiled code because it is bitstype and immutable.


Since struct literally defines a new type (of type S) and julia has nominative typing, “redefining S” quite literally means redefining the constant S referring to the type of the same name. Changing the type of a const binding is not allowed, because it would lead to having to recompile every piece of code that depends on S. You wouldn’t be able to do precompilation at all anymore, because any method might do @eval ... to change the structs in the containing module. You can sometimes get an error where a package does something like this and during precompilation you get the infamous Precompilation may be fatally broken for this module message. This is directly contradictory to the idea of making code fast by precompiling and caching, so I really don’t see how the seeming usefulness outweighs the benefit of losing precompilation.

The trick with the module only works because the seperate modules allow you to distinguish the two S. Modules don’t create a type:

julia> module M end
Main.M

julia> (s::M)() = "test"
ERROR: function type in method definition is not a type
Stacktrace:
 [1] top-level scope at REPL[2]:1

On the other hand, if you want to get rid of nominative typing, that’s an OK approach to solve the issue of redefinition - I just really doubt this is an approach that will be taken in julia because being sure what a name refers to is one of the most important things for any programming language.

I for one think that giving examples and counterpoints to presented theorems is a valid way of conversation, but to each their own.

That said, @eval is the precise mechanism that makes it really hard to judge what a certain piece of code will do. If you allow shadowing of structs in global scope you also have to handle @eval, since the following in your thought model is valid:

function f()
  @eval struct S end
end

function h()
   @eval struct S end
   @eval g(::S) = "from h"
end

f() # we got S
g(::S) = "example"
g(S()) # "example"
h() # we got S_1 and g(::S_1)
f() # we got S_2
s2 = S() # create an instance of s2
h() # we got S_3 and g(::S_3)
g(s2) # MethodError :(

@eval operates in global scope, so by allowing the shadowing of structs you can very easily create situations where you’d expect a function call to work but it in fact doesn’t because the type is different even though their names are the same. Worse, such a function could come from any module you depend on, since (by your own example above) modules can add new methods to existing functions. This can suddenly and very unexpectedly change the behaviour of your code, simply by loading a dependency. That sounds like a very bad idea.

Now, as mentioned above, if we didn’t have @eval, we would not have this problem at all, which is why I brought it up as a counterpoint in the first place. That’s not a feasible thing to remove from the language though.

The compiler has no way of knowing how it should specialize that function for the new type. From its point of view, there’s a method that takes a certain type X, that type X, and a new type Y which may or may not have the same name as X but with no way of knowing in what relation to each other they stand. The safe way is to not allow overwriting existing names. The slow way is to just insert dynamic lookups everywhere you access a value of that type. Specializing on types is the programmers job, so the only thing left to do is throw a MethodError.

2 Likes

Interesting!

$ time julia --startup-file=no -e 'using Plots'   
/usr/local/bin/julia-1.5.2 --startup-file=no -e 'using Plots'  24.06s user 0.90s system 86% cpu 28.871 total
$ time julia -O0 --compile=min --startup-file=no -e 'using Plots'
/usr/local/bin/julia-1.5.2 -O0 --compile=min --startup-file=no -e   13.71s user 0.90s system 92% cpu 15.720 total

I honestly don’t understand what’s the point of doing that. Loading Plots is faster, ok, but then any actual computationalyl-heavy operation is slow, so what’s the deal? Also, 24 seconds look a lot, it’s 8 seconds for me on Julia v1.5.3 (and I have a 5-year old laptop)

1 Like

Well, I don’t know. People use python all the time and “ALMOST any actual computationally-heavy operation is slow”, so it must have it uses.

1 Like

Not really since all computationally-heavy code in Python eventually calls into C/C++/Fortran libraries