@Nick
I don’t like to have casts which narrow the range of values in any way be implicit. On 64 bit systems I agree that having numbers close to SIZE_MAX (UInt.MAX) is generally a sign of a bug, but on 32 bit systems numbers near 2 billion are a lot more common, and for ML in particular the chance of “really big array that uses most of memory” is higher than in normal code.
Continuing on with related discussion with @clattner from Discord, I think that we can safely allow widening casts which don’t change the sign to be implicit. This means that UInt8 → UInt16 is implicit, but UInt16 → UInt8 must be explicit. UInt and Int change their bit width depending on the target, so I think the best behavior to encourage portable code is to require explicit casts. So long as the sign stays the same and the cast is strictly widening, the cast is safe to do since the new type can represent a superset of the values the old one could. fp8_e4m3 → fp32 casts may also be worth consideration, since they make it easier to handle things like CPUs which may have a very limited fp8 operation set but support far more operations (ex: trigonometric operations) at fp16 or fp32.
Chris is concerned about an Int vs UInt schism in library APIs, especially given that many programmers do default to Int and due to opinions like those voiced by @leb-kuchen where some programmers may want to disregard the hardware. However, I think that this can be solved in part by a bit of education and by the way the standard library sets defaults. I’ve already seen enough confused python programmers to see that Mojo probably needs to explain integer precision in the manual anyway. Given that, I think we can use that as a segue into talking about using types to constrain what the program can do. For instance, Python’s len builtin already throws an error if the argument’s __len__ returns a negative number [1], and we can provide that fact along with an explanation that, instead of providing a runtime error for that, we make it so that users get LSP feedback when writing an invalid __len__. The default return type of len is also very important. With it, we can nudge people towards using a particular type. Right now, Mojo’s len returns an Int, the same as Python does, but actually violates the expectations of Python developers by being able to return negative values. If we instead use UInt to constrain that, we are expressing the same thing as Python does, except at compile time where LSP feedback can help users who write incorrect __len__ implementations.
Both C++ and Rust have both a UInt equivalent and an Int equivalent, but my personal experience and the data I gathered from a variety of projects showed that the UInt equivalent is far more popular. In Rust, I think that is partially due to both having the len(arr) equivalent return the UInt equivalent. In C++, with implicit casting everywhere, developers still use the UInt equivalent more, despite there being no extra effort needed to use the Int equivalent everywhere in their own code.
I think that some of the confusion in Mojo comes from the how many of us learned to code, with loops like for (int i = 0; i < N; i++), or from environments where int is the default integral type. One bold option we have is to rename both Int and UInt to something else, such as Size (UInt) and Offset (Int), and not actually have an Int type, which may be a bit jarring to people to begin with, but should help them consider what type they actually want. This helps sweep aside the ingrained “int by default” habit than many people have, and I suspect that we would find the choice between Size and Offset less controversial for the return type of `len. I also think that, perhaps, lots of explicit casts may push people towards the unsigned version even if they would normal default to the signed one.
There is also the question of negative indexing. As I’ve said before, I think that having it everywhere by default is dangerous since many people from languages without that convention may not thing of it, and simply cause errors when it is used. By making negative indexing an opt-in, we can attempt to encourage only those data structures which have handled negative indexes to let users provide them. I think that the Indexer trait is a great triumph of Mojo, in that it lets us have the best of both worlds, zero-overhead unsigned and negative indexing support in one package, even if it continues the venerable tradition of “making writing good library code require knowing the language better than writing application code”, which is seen in many, many languages. I did look around and most CPUs handle immediate (in the assembly meaning) negative offsets from a pointer nicely, but I tried a few DMA accelerators (Intel DSA and AMD PTDMA), which both promptly blew up at me when I asked for a negative offset as part of a gather, and libverbs, which is the API that RDMA usually goes through on Linux, along with many other MPI implementations, does not believe in negative indexing at all. Meanwhile, MPI_Gatherv uses C int for indexing and for counts, and NCCL uses size_t. As a result, if we choose a single default, we need to pick whether to force ML programmers to do tons of casts or HPC programmers. If we use Indexer as the default, we can easily adapt to what external libraries expect for minimum performance loss, and then what matters is our internal default, meaning what len returns. Given that the expectation of python programmers is already that len can never return zero, it seems silly to me to not encode that in the type system.
On another note, Chris made the comment
We don’t encode < 100 into types.
This could be something we want to explore at some point. Ada has seen a lot of benefits from it in safety-critical software and having an imperative language with that capability well integrated that both looks like something more programmers are familiar with and is older than C would likely be something that a lot of industries would be interested in. While a lot of work, exploring tracking value ranges in the type system might allow us to make most implicit casts safe, especially when combined with refinement types.
[1]
>>> class Thing:
... def __len__(self):
... return -1
...
>>> len(Thing())
Traceback (most recent call last):
File "<python-input-1>", line 1, in <module>
len(Thing())
~~~^^^^^^^^^
ValueError: __len__() should return >= 0