Explicit error during compilation if variable name is reused with different type
If you reuse variable name in a class and assign a different type of object to it you can't access new or old class attributes:
class Circle():
radius: float
def __init__(self, radius):
self.radius = radius
class Square():
side: float
def __init__(self, side: float):
self.side = side
actor main(env):
shape = Circle(3.14)
print(shape.radius)
shape = Square(5)
print(shape.side)
env.exit(0)
Building the code above produce the following error:
Compiling variable_reuse.act for release
[error]: Attribute not found side
╭──▶ variable_reuse.act@17:17-17:21
│
17 │ print(shape.side)
• ┬───
• ╰╸ Attribute not found side
If you just do this instead:
actor main(env):
shape = Circle(3.14)
print(shape)
shape = Square(5)
print(shape)
env.exit(0)
It will build and it produces the following output:
> ./variable_reuse >
<variable_reuse.Circle object at 0x1006bef60>
<variable_reuse.Square object at 0x1006bed60>
But you cant really access any of the attributes since compiler will throw and error that attribute is not found. For example:
actor main(env):
shape = Circle(3.14)
print(shape.radius)
shape = Square(5)
print(shape)
env.exit(0)
Does not build:
15 │ print(shape.radius)
• ┬─────
• ╰╸ Attribute not found radius
This can get confusing in big chunks of code where you accidentally reuse variable name. Compiler should probably throw an error that variable name has been reused.
The underlying problem here is that the assignment syntax we've inherited from Python is actually ambiguous in a typed setting: does the second assignment to shape mean that the shape value is just updated (while its type is preserved), or does it introduce a completely new variable (that could be given a new, unrelated type)? So far the compiler has picked the former interpretation, primarily motivated by the way we expect reassignments to work inside loops. And in order to give shape a type that can be preserved, the compiler finds the least common supertype of Circle and Square -- which is the built-in class value that has no accessible attributes.
However, as is clear from the example, this behavior has its drawbacks. One solution we've toyed with (but not yet decided upon) is to require updateable variables to be introduced by the var keyword, just like the state variables of actors already are. Without a var prefix, the assignments to shape would then define distinct variables of distinct types, with their respective attributes accessible as expected.
On the other hand, the current form of variable update would have to be written like this:
var max = 0
for x in whatever:
if x > max:
max = x
Food for thought.
I don't understand the value in inferring the type of shape as an common subset between Circle and Square. It is one thing to allow reassignment but common type is... weird? I'm struggling to come up with an example when that actually makes sense.
If we reassign a variable, wouldn't it make sense that it can only be reassigned things of the same type - and exactly the same type, not some parent type!?
Not weird at all. A variable could for example be initialized to gdata.Absent before starting a search that potentially updates it from a set of list elements, which are effectively of type gdata.Container. Type inference concludes that the variable's static type must then be the least common supertype of these alternatives, that is gdata.Node. Another common pattern is to initialize a variable to None and then later on update it with values of some concrete type, making the variable an optional of that type (which is the least upper bound of None and any other type).
But I agree that having value as the common ground between any pair of unrelated types is far from satisfactory. Not only does it obscure certain type errors (that I think would have been preferable in the original example), it also slows down constraint solving by offering too many solution alternatives. The new idea of supporting unions through explicit declarations is expected to make value redundant, though, so it should preferably be removed along the way.
@nordlander I find it natural that it could be a union, so either Absent OR Container and having to isinstance check etc around that. I think the common denominator thing is the weird part - I struggle to see when that is useful.
But anyway, maybe we should just revisit after unions proper. It's probably easier to talk about then.
@plajjan You might be thinking more in the line of a functional programming tradition, where a datatype is made up by a (closed) set of constructors (subclasses), and the common parent class consists of virtually nothing of interest -- it's just a name for a particular set of constructor alternatives one must check for at run-time. And we should certainly strive to improve our support for that style of programming, for example by implementing Python's full-blown pattern-matching, and adding a means for declaring a class hierarchy closed. Our planned support for proper unions is also a step in that direction.
But we also cannot deny our object-oriented heritage via Python, where (open-ended) sub-classing and so called late binding are core themes. In this tradition the notion of "common ancestry" is quite central. In fact, a very common example that demonstrates the benefits of OO sub-classing refers to Circles and Rectangles as well, but typically connected via common superclass Shape whose methods capture everything one can do with a shape in general (draw it on screen, compute its area, rotate it, etc). In this style there is rarely a need to inspect what subclass a particular object actually is, since everything one would want to do based on this information is (ideally) already embodied in the overridden ancestor methods one invokes via late binding.
Our type-checker computes common ancestry types all the time, since that's what's needed whenever a function returns Circles as well as Rectangles, or a list is populated with both classes of objects -- or when the same variable is assigned values of both types. Common ancestry computations is also the basis for the inference of optionals as well as our currently crippled form of unions. And the latter will improve, as it were.
I actually cannot see any contradiction between supporting both the functional and the OO idioms in this way. With the planned compiler improvements, you'll have the option of building closed Haskell-like classes of constructors that are taken apart using pattern-matching, or object-oriented class hierarchies that are destructed via common ancestor methods and late binding, or some interesting hybrid in between. The only drawback we're currently seeing is due to the design mistake of making value an implicit ancestor of everything. Once we back off from that mistake and add proper unions we should end up with a quite beautiful type system, IMHO.