As I see it, const
is a guarantee from the compiler that the type will not change (it throws an error), plus a promise that I will not change the value, or that I’m ok to pay the price of some subtle errors if I try to do it (since I can).
In this light, I think that
struct S ... end
module M ... end
should be thought of as the analogous of
const S = struct ... end
const M = module ... end
The types cannot change (they must remain DataType
and Module
respectively), while the “values” can. The latter (the module) already works this way: if you execute module M ... end
twice, you simply change the value of the constant M
. This is exactly the same thing of doing const c = 0; c = 1
.
Now, let me discuss your example with f
, g
, h
, S
and @eval
. The functions f
and g
don’t play a significant role, so let me inline their body and get rid of @eval
. Executing struct S end
twice should be a no-op the second time, because we are not really changing the structure; so, to make the example more significant, let me use two different definitions of the structure. The example becomes
struct S x::Int end # S_0
g(::S) = "example" # g(::S_0)
g(S(0)) # "example"
struct S x::Char end # S_1
g(::S) = "from h" # g(::S_1)
struct S x::Int end # S_2
s2 = S(0) # s2 isa S_2
struct S x::Char end # S_3
g(::S) = "from h" # g(::S_3)
g(s2) # ALERT!!!
As I mentioned at the end of my previous post, I can see at least two distinct ways to handle this situation. The first is lazier and purer on a conceptual level, the second is more convenient for an interactive user. I’ll explore the implications of both.
Option 1: old methods don’t apply to new shadowing types
We accept that the line marked ALERT!!!
is a MethodError
. I don’t see anything particularly bad about that. Just as changing the value of a const c::Int = 0
can lead to errors, so may do wildly redefining structures. At least we have moved the error a bit forward, instead of throwing it on line 4 (at the first redefinition of S
). A careful programmer could define a g(::S)
at the right spot to deal with an argument of type S_2
and everything would work. Actually, he is still in time to do
g(::typeof(s2)) = "whatever"
and be able to add a methods that handles arguments of type S_2
.
Option 2: old methods apply to new shadowing types (retroactive dispatch)
The other option is to recognize that there is a chain of overrides S_0 -> S_1 -> S_2 -> S_3
. This could be represented by a property such as shadows
or overrides
, so that
S_3.shadows == S_2
S_2.shadows == S_1
S_1.shadows == S_0
Now, whenever we have a function g
with a method g(::S)
that accepts an argument of type S_1
and we feed in an argument of type S_2
, we recognize that S_2
was shadowing S_1
and decide that the method is applicable. We then have to compile a new specialization specific for S_2
and we are good to go.
This can be much more convenient for interactive use, but I also recognize that it can turn out to be quite messy.
Conclusion
Both the approaches seem reasonable to me, and there may be many others. In both cases no dynamic lookup is required, and the behavior of structs
becomes more consistent with that of const
ants and module
s.
Addendum
I feel like an additional clarification is required. The name “nominal/nominative type system” might be a bit misleading. The equivalence and compatibility of types is not determined by the name to which they are bound in current namespace (S
in the example before), but rather by their true identity as retrieved with objectid
. Namely, it is ok to do
struct S end
const T = S
g(::T) = "ok"
s = S()
g(s) # ok
because at the time of definition T
was referring to S
.
What I’m proposing is not incompatible with a nominal type system. It is basically just a better way to do the following
struct _S1 end; S = _S1
g(::S) = "one"
struct _S2 end; S = _S2
g(::S) = "two"
# now g has two methods handling _S1 and _S2