Restarting the `int/uint` Discussion


#1

Over the past several months, there have been a large number of discussions about the role of a default “integer” type in Rust. Most recently, the core team posted a decision that was based on a lot of the discussion on Github and here, and based on a lot of discussions that we’ve had on and off throughout 2014.

Unfortunately, members of the core team didn’t participate much in this round of discussions, and some of the considerations that members of the core team had when making the decision weren’t adequately discussed on the RFC thread before we made the decision. Part of the problem was that this long-standing issue wasn’t properly shepherded, despite literally hundreds of comments that have been made on the topic.

In light of that, we want to restart the discussion. Since the latest discussion on Github, Reddit, and here, a bunch of us have spent a number of hours trying to outline all of the possible options, and tried to identify what we consider to be the costs and benefits of each approach. I’ll try to outline my analysis (and opinion) below, based on those discussions.

The goal is to restart the discussion. If you find yourself disagreeing with a cost or benefit that I discuss, or think I’ve missed something, please say so! I tried to incorporate the vast majority of the public feedback I’ve seen so far, plus conversations I’ve had with Aaron, Niko and Huon over the past few days.

TL;DR

  1. The status quo option (int is machine-sized but we encourage people to use i32 by default), while well-intentioned, is problematic.
  2. There are two better options (Designs 2 and 3 below), both of which could be achieved without derailing the forthcoming alpha release. While both options are viable, there is a real tradeoff between them. I’ve tried to outline the basic contours here, but hope you’ll help us dig further into the details of the pros/cons in the ensuing discussion.

General Framing

First, let me start by framing the discussion: it’s about what int should mean in Rust (and if an int should even exist in Rust). The reason there is contention about this topic is that people believe (rightly so in my opinion) that whatever type is called int in Rust will often be used as a default choice for people who don’t have a better idea. This is not just about beginners; even seasoned developers sometimes reach for a “reasonably good-enough integer”.

In Rust, integers are commonly used for several kinds of things:

  • To represent numbers with a domain-defined upper bound, like the number of seconds since the process started, the discriminant of an enum, or an index into an array with a restricted, program-defined maximum size.
  • Math, in which there is no obvious, reasonable upper bound
  • In cases where the upper bound is proportional to the amount of addressable memory, such as vector indexes, node ids, or numbers that represent sizes.

Regardless of which decision we choose, there will be some cases where casts are needed to convert from one type to another. Casts that truncate values can be very harmful and lead to portability issues, and if programmers find themselves needing to cast very often, they are unlikely to have clarity about what the original types were trying to express. This means that we should try to find solutions that keep casts to a minimum, even though we cannot avoid them entirely.

An orthogonal, but related design decision is whether we should allow implicit coercion from one integer type to a larger integer type. This reduces the incidence of casting, and should not generally be dangerous, but the precise allowed coercions might differ from platform to platform (e.g. whether a machine-sized integer can be coerced to an i32 would be different on 32-bit or 64-bit systems).

It is also not controversial to say that if you are using numbers that safely fit into i32, using i32 will usually be faster than using i64 or machine-sized integers (on 64-bit systems).

Status Quo: The int Type is Machine-Size But We Tell People to Use i32

The design proposed in the earlier discuss post aimed to compromise between the current definition of int and a widespread desire for i32 to be the default type by keeping int as machine-sized, but recommending i32 for general (non-indexing) use.

However, given that people are likely to use int as a default no matter what the guidelines tell them, it is problematic to use int for machine-sized types if they are not the recommended default. That is, if we want i32 to be the default, that’s what int should mean. While such a change would entail churn – a real risk at this stage – it is surmountable via a strategy I will outline in Design 2.

On the other hand, as we’ll see with Design 3, it may turn out to be quite sensible to keep int as machine-sized but also recommend it as the default type, sending a consistent message.

Design 1: No int Type

One solution to the problem is simply not to have an int type (but instead have the sized integer types plus something like isize). Instead, whenever you want to use an integer, it is up to you to figure out what the best size for that usage is.

In practice, I believe that this will lead to conventional choices about the best choice to make if you don’t have a better opinion. Those choices might come from our own design guidelines, conventional opinion, or popular answers on Stack Overflow. There is a good chance that there would also be multiple conventional answers, depending on who you ask.

In other words, if we decline to provide an explicit default in the language, or guidance about a default, many people will end up with their own “go to” default. We lose the opportunity to help people make a good decision, or to lead with the design of the standard library.

On the other hand, as with the status quo above, if we suggest a default in the style guide there is very little reason not to make that the default integer type as well.

Design 2: The int Type is an Alias for i32

There are several possible variations on this line of reasoning, but I will focus on the one that preserves i32 as the default, while minimizing necessary casting.

We (Niko, Aaron and I) have given this proposal a good deal of thought (including a plausible strategy for rolling it out before 1.0), and think it’s a reasonable contender for a solution.

  • The int type is an alias for i32.
  • Whenever a Rust method wants to represent an offset or size, it uses isize, which is a machine-dependent size. (The name isize is a straw-man.)
  • Rust has an implicit coercion from smaller types to types of bigger or equal size, which means that you can use an int as an index into a vector on 32-bit and 64-bit systems.

Pros:

  • This design encourages people to use 32-bit integers when they don’t have a better idea in mind.
  • Assuming that their usage can always fit into 32-bits, this will result in faster math operations, and therefore somewhat faster programs.
  • This design encourages people to use a fixed-size integer when they don’t have a better idea in mind. This could help people who are targeting both 32- and 64-bit systems with robust test suites (but who don’t run their suites on 32-bit systems) catch some bugs that would otherwise remain hidden until their code was run on 32-bit systems.

Cons:

  • For programs targeting 64-bit deployments, this increases the chance of overflow errors relative to the other defaults we are considering. While some kinds of programs might still overflow even with a 64-bit integer, there are many domains where 32-bits aren’t enough, but 64-bits is far more than enough (e.g. seconds since the epoch, nanoseconds since program start).
  • When a program uses an int to refer to something proportional to addressable memory (common when working with data structures), this significantly increases the chance of an overflow on a 64-bit system. Because int is the default, this situation is likely to arise even in cases where isize would have been a more appropriate choice.
  • Additionally, it becomes very easy to accidentally write data structures that do not support the full 64-bit range of data on 64-bit systems. For example, Java’s mmap functionality does not support more than 32-bits, because it was accidentally written with 32-bit numbers in mind. This problem would not become apparent until someone tried to use the data structure with a very large number of elements. It would trigger a silent overflow in production, and could not be fixed compatibly (without breaking existing downstream consumers).
  • It would break some existing code that stores the result of a method like pos or Iterator::enumerate in an int field or variable. This isn’t a primary consideration; if the balance of factors leaned in favor of this option, there’s a way to roll it out without delaying 1.0, which I’ll discuss next.

If Rust went with this approach, the roll-out plan would look like this:

  • In Rust 1.0-alpha, we would introduce an isize type.
  • Between 1.0-alpha and 1.0-beta, we would change the int type to be an alias for i32 and change all of the standard library to use int and isize appropriately.
  • For 1.0-beta we would introduce a temporary deprecation for the literal int type used when an isize is expected. This would help people who are currently using int to mean machine-size to transition to isize.
  • In 1.0-final, we would remove the deprecation, making int a literal alias for i32, which would allow people to use the default integer in indexes.

Design 3: The int Type is Machine-Size

This is similar to the status quo, except that we would encourage people to use int as the default integer type, rather than i32. The pros and cons of this choice are largely the flip side of Design 2. I’m not going to repeat all of the details here, so please read Design 2 above.

This is the second option that I think deserves serious consideration. Personally, given the weight of the pros and cons, I am leaning towards this option.

Pros:

  • Decreases the chances of overflows in applications targeting 64-bit when using the default int type.
  • When a program uses an int to represent something proportional to addressable memory, this drastically reduces the likelihood of overflow on all architectures.
  • Virtually eliminates the possibility of accidentally writing data structures that do not work with a very large number of elements on 64-bit architectures.
  • Since the current int is machine sized, this is the only option that doesn’t break any existing code.

Cons:

  • If the particular usage of the integer could have fit into 32-bits, this will result in slower math operations with that integer on 64-bit systems. Of course, there is always the recourse of using i32 explicitly in such cases.
  • If a program is also targeting 32-bit systems, but is robustly tested only on 64-bit systems, Design 2 might more readily discover mistakes involving numbers outside the 32-bit range that would result in incorrect programs on 32-bit architectures. This can be mitigated by testing on the platforms you ship for.

Design 4: The int Type is an Alias for a Bigger Integer

At first glance, one way to further mitigate overflow problems across all architectures (for integers used without much thought) is to make int an alias for an even bigger number, like i64 or a BigInt.

Unfortunately, if the program then attempts to use the default int as an index, it will be forced to cast it down to the size of the index. In practice, this would result in more widespread, dangerous (truncating) casting, which would reintroduce another kind of pernicious, platform-specific overflow problem. It would also significantly harm ergonomics.

Additionally, a default of i64 on 32-bit systems or BigInt on any architecture has a large performance tax that is unwarranted given that it doesn’t even effectively eliminate a large class of overflow problems (the ones introduced by truncation).


#2

Do you mean “semantic” overflow (i.e. someone has a variable that’s meant to be between 0 and 1000, meaning it can overflow to e.g. 2000, without hitting the limits of the contained type), because BigInt completely removes arithmetic overflow?


#4

I updated the post to reflect that I was talking about the overflow hazards introduced by truncation.


#5

It has also been raised before that Rust’s int is accidentally similar to C’s int, leading to occasional incorrectness (I think the signature of #[start] was pointed out), which is another con of int being machine sized.

Of course, in the theme of language precedents, there are other languages that use int for machine-sized types, but I don’t imagine many will interact with Rust as closely or as commonly as C.


#7

Design 2 is probably the most reasonable. I have nothing against Design 1, but newcomers will decry the lack of int. As a type, int is expected to exist in any non-dynamic programming language. We can argue that this shouldn’t be the case and that it’s detrimental, but the fact remains that if Rust doesn’t have something named int, it will suffer for it in the eyes of those who know little about Rust (which is everyone outside the current, small group of enthusiasts).

So Design 2 where int becomes an alias for i32 is sensible. Issues with overflow are very rare but using a 64bit int for int means everyone who uses it pays a perf penalty all the time. Since Rust is aiming for the C & C++ markets (where int is 32bit even on 64bit platforms), people will be commonly hit by this penalty. It would become a Rust “gotcha,” something newcomers are warned about when starting out with the language.

Design 3 is unacceptable not only because of the above stated problem of perf, but because it introduces portability problems that Rust, as a modern language, should be leaving behind in the dust. I’ve spent far too much time of my life fixing crashes caused by code that unknowingly (and often uncaringly) used arch-dependent integer sizes. Let’s not inflict that pain on a younger generation. Code must not silently compile to different behavior on a different architecture.

I don’t think Design 4 can be taken seriously for a systems language.


#9

Can you talk a little bit about the cons I outlined for Design 2?


#10

Can you flesh out the specifics of the incorrectness?


#11

If one wants to interface or interact with a C API that mentions int, it’s not too hard to accidentally transliterate this into a Rust int instead of i32 (or libc::c_int). E.g. an entry point defined via #[start] is expected to have type:

fn(argc: int, argv: *const *const u8) -> int

but the signature of C’s main is

int main(int argc, char** argv)

Now, this isn’t necessarily incorrect (rustc may insert the appropriate conversions into the main that it emits that calls the #[start] function), but it doesn’t precisely match the signature that C uses.

That said, in almost all other scenarios, the c_types lint will warn about the use of int in FFI (one might regard the necessity for compiler lints as indicating the language itself is incorrect?) and machine-generated bindings (e.g. via bindgen) won’t misuse Rust’s int, so I’m not sure how often it will crop up in practice.


#12

Ah!

I’ve personally written a fair bit of production FFI code, and I’m pretty religious about using c_int / c_long, or at least the explicitly sized types. When I first got started with FFI, I learned what the mapping was (including the mapping for int). I would have loved a nice table, though :smile:


#13

Can you talk a little bit about the cons I outlined for Design 2?

Sure, here’s my take as someone who has spent the last ~12 years writing mostly C++.

For programs targeting 64-bit deployments, this increases the chance of overflow errors relative to the other defaults we are considering. While some kinds of programs might still overflow even with a 64-bit integer, there are many domains where 32-bits aren’t enough, but 64-bits is far more than enough (e.g. seconds since the epoch, nanoseconds since program start).

This is entirely anecdotal, but I’ve easily written more than 300k LOC of C++ in my life, a large fraction of which was for large-scale distributed systems at Google. I have never seen a bug caused by a 32bit integer overflow. Not once. Not in the code I’ve written, maintained or read.

I have heard of them in other systems and they’re nasty, but IMO they’re oh so incredibly rare. On the other hand, I have easily spent 50+ hours of my life fixing various problems caused by code that used an arch-dependent integer. I’ve also spent a very large multiple of that squeezing out nanoseconds of extra perf in perf-critical systems; a 64bit integer being slower on the instruction-level (and that’s not even mentioning the cache space hit) is not the first place I’d look for a slowdown. If int is 64bit, people will get hit by this and it will be yet another gotcha to keep in mind when writing perf-critical code.

When a program uses an int to refer to something proportional to addressable memory (common when working with data structures), this significantly increases the chance of an overflow on a 64-bit system. Because int is the default, this situation is likely to arise even in cases where isize would have been a more appropriate choice.

Stuffing more than 4 billion items in a collection is not a common thing. If you’re doing it, you’re probably using a custom data-structure anyway because of the myriad of other constraints of a problem that involves keeping track of more than 4 billion elements. That has been my experience every time I’ve needed to address a problem with such scale.

So you’re either using a data-structure from the standard library (which will not have the overflow issue because of the number of eyeballs looking at it) or you’re writing your own precisely because you know you need to store that many items.

Additionally, it becomes very easy to accidentally write data structures that do not support the full 64-bit range of data on 64-bit systems. For example, Java’s mmap functionality does not support more than 32-bits1, because it was accidentally written with 32-bit numbers in mind. This problem would not become apparent until someone tried to use the data structure with a very large number of elements. It would trigger a silent overflow in production, and could not be fixed compatibly (without breaking existing downstream consumers).

See previous. Either you’re using a std lib structure which won’t have the problem, or you’re writing your own for this use case and will reach for an i64.

It would break some existing code that stores the result of a method like pos or Iterator::enumerate in an int field or variable. This isn’t a primary consideration; if the balance of factors leaned in favor of this option, there’s a way to roll it out without delaying 1.0, which I’ll discuss next.

This shouldn’t be a factor in the discussion, Rust is not 1.0 yet.

Lastly, I’d like to point out that we can’t look at the drawbacks of Design 2 in a vacuum, we have to consider the drawbacks of all the other designs as well. Even if we think the cons of Design 2 are very bad (I don’t) but the drawbacks of all the other designs are worse (they are), Design 2 is still what we should go with.


#14

Additionally, it becomes very easy to accidentally write data structures that do not support the full 64-bit range of data on 64-bit systems.

I want to address this a bit more.

There are 3 problem “sizes” when stuffing items in a collection:

  • If your problem is “small,” your dataset is far smaller than 4 billion items. By definition, you don’t care about a stray 32bit int in a collection that may limit you.
  • If your problem is “medium,” your dataset is possibly bigger than 4 billion items, but not by a lot/frequently. Just enough to be hit by a 32bit int overflow in a rare case you didn’t foresee/test. (This is the case we are concerned with.) In this case, you’re using a collection from the standard library. They don’t have 32bit overflow issues because they’re written by smart people, have many eyeballs on them and are used by countless users.
  • If your problem is “large,” your dataset is frequently if not always larger than 4 billion items. You are certainly aware of this and are probably using a custom-built data-structure because of the other constraints of such a problem. You know you need to use an i64, and in the unlikely case that you don’t, you’ll see it right away when you test your code.

#15

If your problem is “medium,” your dataset is possibly bigger than 4 billion items, but not by a lot/frequently. Just enough to be hit by a 32bit int overflow in a rare case you didn’t foresee/test. (This is the case we are concerned with.) In this case, you’re using a collection from the standard library. They don’t have 32bit overflow issues because they’re written by smart people, have many eyeballs on them and are used by countless users.

So one concern here is that you might be using uints internally to count things, even though the data structure takes e.g. usize. Since uint would implicitly coerce to usize, you might not notice. But calculations on your indices locally, before you even hit the collection, could overflow. If int/uint is a common/default choice but robust data structures use usize, this could be a gotcha.


#16

So one concern here is that you might be using uints internally to count things, even though the data structure takes e.g. usize. Since uint would implicitly coerce to usize, you might not notice. But calculations on your indices locally, before you even hit the collection, could overflow. If int/uint is a common/default choice but robust data structures use usize, this could be a gotcha.

But that’s why we are also adding int overflow checking, aren’t we?

Also, not all collections are RAM-based (e.g. files and databases), so it would still be possible to run into the same problem with design #3 on a 32-bit system.


#17

Another vote for Design 2 here.


#18

If you wouldn’t mind, could you help us work through the Cons listed under Design 2?


#19

But that’s why we are also adding int overflow checking, aren’t we?

Yes, that certainly helps mitigate, but it’s only intended for testing, which requires coverage, etc. Part of the debate here is about whether there are sensible defaults that help prevent overflow that you can’t/don’t catch during testing.

I’m not saying that using uint locally for a usize-based data structure is a knock-down argument, but just that using robust data structures doesn’t solve all overflow problems for clients.


#20

Could you say more about the specifics of this kind of issue? I’ve personally encountered overflow issues more often in less time (but I also deal with a lot of durations in nanos :wink: ), so some concrete horror stories would help put it in perspective for me.


#21

I think the fact that all core data structures use usize (or umem or whatever) extensively, and the fact usize and uint are not synonyms would send a clear enough message that uint being used as indices is only for convenence. If one is to use a data structure that would store a very large amount of data, or is to write any production quality data structures, I expect him/her to understand the difference between the two types. If he/she cannot get this one right, maybe there will be more serious problems in the codebases for him/her to worry about.

So I don’t think the drawbacks of Design 2 would matter too much in practice.


#22

Overall, option #2 seems to me to be the best. I frankly disagree with many of the concerns given for option 2.

In particular, I want to hone in on the Java example. Java does not (or did not for a long time) actually have a type designated as pointer width. It does not even have unsigned integer types. It’s in a very different domain from Rust where that sort of mistake would be much more likely to go unnoticed, and where there isn’t really a correct solution they should have used.

Also, despite your point about seasoned developers, I find the choice of defaults for integers to be largely a concern for newcomers to the language. In Rust, data structures are quite finicky to write already, often requiring significant unsafe code and intricate knowledge of the type system. I don’t expect many newcomers to the language to be writing brand new collection types and I don’t expect language veterans not to know at all about the isize type. I do know that when I am writing collections or unsafe code, I’m super careful to use things like checked arithmetic, because overflow is possible before you even actually allocate. Maybe I’m wrong about this, I don’t know, but it seems like the wrong thing to focus on when we’re talking about the default type.

I feel like the “more types will overflow” argument is just “64 bits is larger than 32 bits” (I actually think option 3 is arguing for i64, not a platform specific int size, because if it isn’t one of the pros isn’t really accurate). Which is true, but doesn’t really solve the problem. It’s easy to think of domains where 64 bits aren’t enough (anything involving multiplication) so it’s at best delaying the problem. In fact, the fact that the type is larger might make people less cautious about checking for overflow, leading to even more problems (empirically I wouldn’t be surprised if many fewer overflow checks are done for 64-bit than 32-bit integers).

The isize argument is the strongest (since I agree that people will use int as an index) but I’m still not really convinced it outweighs the cons of solution #3. Performance does matter, and architecture specific anything causes huge practical problems (because most people only test on their own architectures, far more than will ignore the existence of isize). And there is also the familiarity argument: I suspect that in practice i32 will be very familiar to anyone coming from a number of other languages (Java, C#, and in practice C and C++).


#23

The Status Quo and Design 1 are the only reasonable designs.

Having an alias for i32 is superfluous, and leads to code that needlessly mixes ints and i32s. And newcomers can easily be shown i32. Especially if the Guide actually used them. Or just stick a 72pt sentence in saying when in doubt, use i32. This is really of minimal concern. So Design 2 is out, and the argument against Design 1 is specious.

And users simply should not be encouraged to use a pointer-sized type for general data. So Design 3 is out. In fact, when users don’t consider size at all, the only safe default int is BigInt. So Design 4 is safe, but with overhead.

Rust is a safe language, and forcing newcomers to think about the width of their data (and thus what values they expect) is not a bad thing. I’m glad that Rust made me abandon the idea of the “default int”. I’m a better programmer because of it, and I hope this philosophy is not lost.