C's Flexible Integer Sizes Were Not a Design Mistake

2 days ago (pikuma.com)

> Language types such as char, int, short, and long do not come with a guarantee of how many bytes they occupy in memory.

Char is actually guaranteed by C to occupy exactly 1 byte in memory. It’s just that a byte can have more than eight bits in C. “Byte” is simply the smallest unit of memory addressable by a pointer.

Further down the article acknowledges that “C requires char to have at least 8 bits (CHAR_BIT >= 8), not exactly 8” and mentions the Honeywell 6000 as an example of a C implementation with 9 bits (and 36-bit ints).

Historically in computing, the size of a byte was hardware-dependent and not standardized. The Wikipedia article on “byte” cites Knuth’s 1968 TAOCP where byte denotes a unit which “contains an unspecified amount of information […] capable of holding at least 64 distinct values […] at most 100 distinct values. On a binary computer a byte must therefore be composed of six bits”.

I agree that the C flexible integer sizes were still necessary at the time of its creation, when some important computers still had word sizes that were not powers of two.

Nonetheless, I started to use C for programming only in 1990, when I got access to the Microsoft C and Borland Turbo C compilers.

At that time, 36 years ago, the C flexible integer sizes were already obsolete.

Since that time until now, while using C on a great variety of computers, from servers and workstations to the smallest microcontrollers, I have seen plenty of portability problems created by the existence of the flexible integer sizes.

The only programs that had no portability problems were those that never used the flexible integer sizes, but only integers with a definite size, e.g. 8-bit, 16-bit, 32-bit or 64-bit.

While sizeof solves the problems of memory allocation or copying, it does not help in preventing unexpected integer overflows, because even the size of "char" may be unknown, and even if the size of "char" is known, writing code with multiple paths that would check or prevent overflow for different integer sizes is very cumbersome.

Flexible integer sizes would work well only on the old computers, where integer overflow generated a hardware exception, so installing an overflow handler would have been sufficient to make the C code work correctly regardless of the size of the native integers.

  • The fixed size int types int32_t and friends weren't introduced until C99. Microsoft held out until 2013 before it put <inttypes.h> into Visual Studio!

    So there has been a really long time in C's evolution where we haven't had fixed size types which has been a super annoying mess of #ifdefs in portable code.

    The variable size ints have allowed some super weird architectures though. I remember looking at the datasheet for the Motorola 56000 DSP and noting that the C compiler set char = short = int = 24 bits! That was because the hardware could not address anything smaller than 24 bits. I think long could be 48 bits.

    • The product is named Visual C++, that it happened to do C was always kind of a sideshow as far as I could tell. Aside from that, I seem to recall that VC++ had the WORD, DWORD, and eventually QWORD macros to specify unsigned 16, 32 and 64 bit types respectively.

  • Per the C standard `sizeof(char)` is always 1, regardless of how many bits it has.

    • because it's the unit of addressable memory that C is concerned by with sizeof, otherwise its values still depend on CHAR_BIT

  • > At that time, 36 years ago, the C flexible integer sizes were already obsolete.

    This is a highly ignorant comment. You're confusing the fact that you only had to work with a single target architecture with the whole concept of multiple processor architectures being somehow obsolete, as if there was a sudden law of nature that forced every single computer, being full blown HPC stuff or small microcontrollers used in embedded applications.

    Take a look at arduino. They still have 16-bit models out there. Also noteworthy, it seems some DSPs also have ints larger than 32 bits.

    • The parent is completely right in the sense that for actually portable C code it was always better to use fixed-width integer types which were chosen for the problem to solve instead of target hardware capabilities.

      For instance if your integer arithmetic needs to happen with 32 bit precision (no matter if the code runs on a 16- or 32-bit CPU), there is no scenario where using 'int' makes sense. Instead you'd use a fixed-width 32-bit integer type and accept that math operations are compiled into two instructions on a 16-bit CPU.

      And OTH if you only require 16 bits integer width, there's not much point in picking a 32 bit integer type. Since two's-complement integer encoding has been standard since at least the 70s, the CPU can do narrow operations in the native register width. Any overflow/wraparound is still correct when only looking at the lowest 16-bits of the result.

      3 replies →

    • As I have said, I have not worked with a single architecture.

      Before 1990, I had worked with a variety of ISAs, from IBM mainframes and DEC minicomputers to many kinds of microprocessors.

      After 1990, I have used C on a great variety of x86, Motorola 68xxx, IBM/Motorola PowerPC and many generations of ARM ISAs.

      Even if you use explicit 32-bit integers in a program, that will not create any correctness problem when the program is run on 16-bit microcontroller. At most such a program may have a suboptimal performance. Performance problems are much easier solved during porting than obscure bugs.

      There have been some popular DSPs with 24-bit integers, e.g. Motorola 56xxx. Nonetheless, nobody would want to run on such a DSP a program that was written for another kind of CPU, even for another kind of DSP, because the performance would be pathetic. Any program for such a fixed-point DSP, even when derived from an existing program, would need to be rewritten while using at every point in the program the knowledge that the size of "int" is 24 bits (because the programs for fixed-point DSPs need copious amounts of scaling operations, to avoid overflows and underflows), so such a program should not actually use "int", but it should typedef an "int24_t", to make this assumption explicit.

Where the flexible integer sizes break the most is when dealing with ABIs, which weren't really a concern before dynamic linking existed but are very much a concern today. We've also, for some reason, decided that the standard way of defining a library ABI is with a C header. That means that everyone has to worry about precisely defining integer sizes, as well as more esoteric types like size_t or intmax_t. Good writeup on all that here: https://thephd.dev/to-save-c-we-must-save-abi-fixing-c-funct...

It was intentional, sure. It was an attempt to solve a particular kind of problem.

In hindsight though, it was a mistake.

Evidence: when the world moved to 64 bit, we didnt just let int mean 8 bytes on amd64. That's a clear acknowledgement that the design was not correct once we understood things better.

I think it didn't work out well, because "int" being different size makes programming difficult. For example, a system must manage up to 100 000 records. Can I use int for record number? What if it is 16 bits? What if I need to send data between machines, how can I use "int" if it can be different size?

Probably someone noticed that it is inconvenient, and on 64-bit machine ints are still 32-bit and not 64.

The computers with 16-bit ints or 9-bit bytes are long gone, but the language still has to carry that legacy.

Good article.

C would probably not have survived unless it had this flexibility.

But its not justa historical thing. Today there are modern platforms like DSPs that have 32bit sized char, because that is the smallest addressable type. These platforms depend on C for tool chains, even if most "portable" C wont run correctly on them. The fact that you can build hardware like that, and not have to invent a new language / dialect to program them is a huge win for the world.

<edit> I didnt see the footnote about DSPs at first read </edit>

  • This kind of flexibility is a purely historical thing.

    On modern computers, it is impossible to write correct C programs that are agnostic about the true size in bits of the "flexible" types char, short, int, long and long long.

    If your program must depend on assumptions about the size in bits of the integer types, those assumptions must be made explicit, by using types like int16_t, int32_t etc.

    Writing correct programs that are agnostic about the integer sizes is possible only in programming languages that allow the programmer to install an integer overflow handler even if the CPU does not generate a hardware exception for that, in which case the compiler must insert appropriate overflow checking instructions that would invoke the installed handler when necessary.

    This problem did not exist on old computers, where there were hardware exceptions for integer overflows, so even in C you could install a signal handler for SIGFPE, which would also be invoked by integer overflows.

    • That's not true. All the basic types have minimum sizes, so its perfectly reasonable to write software that stays within those bounds. You can avoid any overflows if you know the minimum limits.

      2 replies →

  • C survived because UNIX carried it.

    • I don't agree. UNIX was an extremely niche operating system until Linux won the data center, at that time both C and C++ were already extremely popular outside the UNIX world. C won because it was so easy to adapt to new hardware architectures (even GPU shading languages are just minimally extended flavours of C and C++).

      18 replies →

  • Not sure why you would need to invent an new programming language when we're still strictly talking about data types. You just introduce a new data type for the hardware if that's what's necessary. You don't need a whole language.

    This is why I think so many C developers have no clue what they are doing. They just take whatever decision was made in C as gospel instead of thinking of everything being up for negotiation.

Thank you for the article! Do I see a Turbo-C screenshot there or am I imagining it? It was my first IDE (I didn't know that's what it was called) when I started programming. I sometimes miss it, it was really good, especially the help system.

I agree with the article of course. I think most confusion comes not having learned about the purpose of having them be defined based on the architecture in the first place. It took me a long time before I stumbled upon how they really worked and why, because while I started it was either x86 or nothing. When x64 showed up, suddenly it became relevant and everybody started learning about C types more in depth as they ran into issues with sizeof.

Also, misuse in data protocols is where I think the bad reputation of the flexible type sizes came from. stdint was desperately needed for that reason and it came a bit late.

> A 'plain' int object has the natural size suggested by the architecture of the execution environment.

Shouldn't they be 64 bits on most modern systems then?

  • On x86-64, you need an extra prefix to do 64-bit operations (while 64-bit addressing is the default), so it's a question of "are you sure you need the 64 bits and 32 isn't enough?"

  • One a modern computer, the ideal integer type is usually smaller than 64 bits. Even though the arithmetic units handle 64 bits in one op, 64 bits take up twice the memory bandwidth, and half as many fits in a cache-line, and given that a modern computer is usually bound by memory access, smaller is better. So you can argue that both new and old computers prefer 32 bit over 64, but for different reasons.

    The added compatibility between 32 and 64 bit systems, is another reason. Also if you choose int to be 64 bits, what would you make short? 16 or 32 bits? It makes much more sense to keep int 32 bits, and reserve long / long long for 64 bits.

  • Memory addresses being 64 bits due to needing to address more than 4 GiB memory doesn't mean most integer instructions operate most efficiently with 64 bits. Instructions for 32 bit integers are still more efficient than 64 bit, whereas 16 bit operands require a prefix byte meaning they're less compact and cache efficient (on AMD64 anyway).

  • > Shouldn't they be 64 bits on most modern systems then?

    Arguably so, but then one would lose the ability to natively name 16-bit integer types because "short" would be 32 bits.

    An earlier comment addresses x86-64. AArch64 (pedantically, the A64 instruction set used for AArch64's 64-bit execution mode) is similar, in that addresses are 64 bits wide but ALU instructions typically encode a width bit, called "sf", that selects either 32- or 64-bit data registers and arithmetic. See, for example, https://arm.jonpalmisc.com/latest_aarch64/add_addsub_ext .

  • It should indeed, and in hindsight it would have been better to move int to 64 bits (especially for C's integer promotion, which only really makes sense when the promotion happens to the register width.

    But porting 32-bit code to 64-bit was a big deal back then, and C99 with its new fixed-width integer types overlapped with the first AMD64 CPUs (and Microsoft's MSVC didn't start to support C99 until around 2015 anyway), I guess keeping int on 32-bits in the popular compilers was deemed 'safer' for porting existing code. I guess we can already be lucky that all the big compilers agreed on the same int width.

    • I've always believed that they kept 32 bits ints on 64 bits CPU as a default because going full 64 bits would make the code and data structures bigger for "no reason" (it's not often that one hits the 4.10^9 limit in system code (if you don't count timestamps, that is)). For instance, a load-register-with-immediate instruction would normally take 5 bytes (opcode+value) on 32 bits, but 9 bytes on 64.

      2 replies →

The problem begins when you start mixing the traditional types and (u)intN_t, because the latter are merely aliases for the internal types, and it messes up overload resolution. All relevant platforms have pretty much agreed the size of char, short (int), int and long long (int). They have different opinions about long (int) and thus an int64_t might use either long (int) or long long (int).

So the best solution for nowadays is to use just char, short, int and long long (and make strong assumptions that these are exactly 8, 16, 32 and 64 bits wide respectively), never use long or long double. Never use (u)intNN_t. Then you are good.

Those caveats of the past (but int might be 16 or 36 bits), are exactly that. An artifact of the past. A historical curiosity. Not relevant for today or the future. No, I don't believe for a second that any future platform will change their size.

Platforms also still disagree on the signedness of char, so when an 8 bit numeric type (as opposed to an ascii character type) is needed, one should always explicitly specify signed char or unsigned char, both of which are separate types from char.

Further things of note: platforms also have agreed on little endian (so called "network byte order" is dead and should never be used in new protocols, because it forces everyone to convert) and on IEEE memory representation of float and double. Contrary to popular belief the main floating point operations (+,-,*,/,==,<,>,<=,>=) are also precisely defined and always behave exactly the same (leaving out strange edge cases such as denormals). And yes, of course platforms have very long agreed on twos-complement for negative integers. This even made it into the standard at some point, I believe. Same happened with the memory layout of a vector<>, which in the past wasn't standardized, but because everyone of course did the obvious (and made it the same as a normal C array), it was added to the standard later.

What I am saying, what the C++ standard guarantees isn't everything. There are much more guarantees modern C++ code can (and should) rely on.

  • > All relevant platforms

    Don't make me tap the sign: the majority of processors running C are weird little dirtbag chips of 16 bits or less sprinkled by the dozen.

    • but the C for them is its own little world playing by its own rules, separate from C used everywhere else.

      And I bet that when you have only 16 bits of address space, you care how many bits every integer has.

  • > No, I don't believe for a second that any future platform will change their size.

    ILP64 (wherein int is 64 bits) exists. It's not very popular, but it exists; e.g. ICC supports it. So it happened in the past once already; it may again happen in the future. In any case, predicting the future is very hard, you really shouldn't be doing this.

    > IEEE memory representation of float and double

    Wait, what? I'm fairly certain that a) IEEE does not mandate the in-memory representation, and b) ARM actually uses big-endian byte order for floats/doubles when storing them in memory.

    > always behave exactly the same (leaving out strange edge cases such as denormals)

    So not always, but please pretend so? Yeah, no, thank you.

    > platforms have very long agreed on twos-complement for negative integers. This even made it into the standard at some point, I believe.

    Only in C23. It was explicitly rejected for C++ 23 (and C++ 26 too, I believe).

    > but because everyone of course did the obvious

    No, not everyone did the obvious. That's why it took so long to standardize because divergent implementations existed.

    > There are much more guarantees modern C++ code can (and should) rely on.

    As long as you only use only GCC (or Clang) exclusively, yes, you can. Otherwise, no, you can't and shan't.

    • > ILP64 (wherein int is 64 bits) exists. It's not very popular, but it exists; e.g. ICC supports it.

      ILP64 is problematic for existing code: there is lots of stuff like hashcode computations using uint32_t with multiplications, relying on the C standard guaranteeing wraparound for unsigned overflows. But with 64-bit int, uint32_t will promote to a signed int, and overflows will thus be undefined behavior. This problem already exists with uint16_t multiplications on current architectures, but moving the problem to uint32_t will cause trouble for a lot of existing code that thought using fixed-size types like uint32_t would be safe.

    • > I'm fairly certain that a) IEEE does not mandate the in-memory representation,

      That's not how I interpret section 3.2 in the standard[1]. Figure 1 seems quite explicit in how a single and a double should be encoded. The section on extended values specify they can be encoded in an implementation-depended manner, which makes the case stronger IMO.

      edit: I note that in the 2008 revision[2], it's more explicitly mentioned that the specified encoding is a binary interchange format. So that's a lot more specific than the original.

      [1]: https://pub.sergev.org/doc/ieee754-1985.pdf

      [2]: https://pub.sergev.org/doc/ieee754-2008.pdf

But not having fixed size integers (or integers tied to the size of a pointer) was. Both can be useful

  • What is the size of a pointer though?

    On Intel 286 we had a 16-bit machine word and 24-bit addresses. A pointer wasn't just two machine words concatenated - the upper 8 bits were stored somewhere else - a segment register.

    On modern machines we don't (usually) need to consider this because we have a single linear virtual address space, though the size is architecture dependant - usually above 40 bits and below 64. Most common size is 48-bits, but also up to 57-bits with 5 level paging enabled.

    Either way we round up to 64-bits to store the pointer as one integer. C optionally provides types `intptr_t` and `uintptr_t`, which are integers large enough to hold the value of a pointer. Converting a pointer to `intptr_t` and back to the pointer type results in a pointer that compares equal to the original.

    However, there is no guarantee that a pointer converted to `intptr_t` and back to a pointer can be dereferenced! It works most of the time because of our linear address space and non-use of segmentation, but segmentation can still be used - the FS and GS segment registers are still available on x86_64 and are commonly used for thread local storage. If you take a `thread_local T*`, convert it to `intptr_t`, and then convert it back to a `thread_local T*` on another thread and attempt to dereference it, then despite the pointers comparing equal, they dereference to different virtual addresses.

    Integers tied to the size of a pointer would have been misguided. Pointers are not integers! (They just happen to use an integer in their representation).

    Another one, `size_t` is supposed to represent the maximum size of any object. However, that's also not well-defined. The maximum object size on the Intel 256 would have been 16-bits, because that is all you can fit in a single segment.

    On a modern machine, a `size_t` should really be 48-bits (4LP) or 57-bits (5LP), because we can't have an object larger than our maximum virtual address size - but `size_t` is typically 64-bits.

  • It turns out that you don't want integers the same size as a pointer because somebody might squirrel away capability bits in your pointer type (see CHERI) and you definitely do not want integers with capability bits.

    Rust originally says that its types usize and isize are the same size as pointers, but this was ret-conned in later Rust to say actually they're the same size as addresses for this reason.

  • At the time its was probably very hard to know what the fixed sizes should be.

    • PL/I among other systems languages predating C, had the ability to explicitly define bit sizes for its types.

Kind of, the mistake was not doing like PL/I where besides default machine specific sizes, the developer could explicitly assert the required sizes.

  • Is that really that much of a big deal, though? Before stdint, if one needed that level of accuracy, one would do your own equivalent of stdinit by hand, and adjust those definitions when porting to another compiler/platform. The same goes for your local boolean type.

    I think the only real annoyance is that each programmer/team did it with their own convention (I32, INT32, i32, int32, WORD, Word bool, BOOL, Bool, etc., etc.); standardizing helps with putting everyone on the same page more than it helps porting. It doesn't prevent people from reverse-typedef-ing standard names to local "dialectal" names, though.

    But I also think that one should only rarely use raw integer types, in an ideal world; the elephant in the room is that typedef is kind of the second "billion dollars mistake" [1]. C is a weakly typed language and there's no practical way to undo it (besides transpilation), so there's double no point to leave behind raw types.

    [1] For those not too familiar with C, typedef defines a "type alias", not a type: https://en.cppreference.com/cpp/language/typedef

  • C99 kinda fixed that with the `(u)int_leastN_t` types (which are hardly used in practice though). And shame that it took Microsoft 16 years to even start supporting C99 though so we were basically forced to keep using our own custom integer typedefs long after the C standard had fixed the issue.

    • Agreed, but it could have been there since day one, given the languages in 1960's.

      Well, Microsoft has considered C done for quite some time, and after C++20, they don't seem to be in a hurry to keep up with ISO either for C or C++ (there are discussions on support channels about customer relevant C++23 and C++26 features, none on C past C17), similar to how Apple and Google are handling their in box compilers as well.

  • I thought most implementations of C have stdint (intN_t, leastN_t and fastN_t etc).

    • These are C99 features which MSVC only got around 2015 (of course a decade later those are safe to use in portable code).

No one thinks that ptrdiff_t should be a fixed size. It is quite obviously the integer type you would get from subtracting two pointers, which is naturally tied to the word size of the machine you are using. C's original "int" type is what we would now call ptrdiff_t.

I feel like the article glosses over the fact that (to my mind) `int_fast32_t` and `int_least32_t` are a much better solution than "int is a random size good luck"

If you code exclusively using those types (and the `*ptr_t` ones) then you precisely express to both the compiler and the next person reading it what is supposed to be in those variables.

  • I came here to say exactly that. There’s int_leastN_t for storage and int_fastN_t for computation. Stdint.h really gets a bad rap here.

    • fwiw depending on use cases you might actually want to be using the fast variants for storage too, for example on arm64 you'll get aligned memory loads

So, writing xc [1], I took the opposite approach, but the real reason for that was more cross-platform compatibility - xc compiles for Mac(M series), Win64, Linux (x86_64), iOS, Android, WASM, m68k, Arm A9, and 6502. The basic types in xc are spelt {u,i}{8,16,32,64} and since the platforms covered range through 8-, 16-, 32- and 64-bit machines, being explicit about the size of the data-structure was a lot more useful than it being implied.

I can see the argument for "an int works on the natural machine size". But it starts getting a lot more complicated when you have structs - suddenly byte positions are very important (as is 'sizeof' :), and if you're running the same code on different platforms, and using pointers to access them, well you need to be careful...

Fixed-size types (and we've more or less given up on non-power-of-2 sized primitive types) force you to think about the size of the type you're using at the point of creation, and if you really do want 'an int is the size of the local machine', you're free to 'typedef u32 int;' in a platform-specific file - I deliberately did not use 'int', 'short', 'long' etc. in the language.

[1] https://compile-xc.org/compiler/language/types/

Meh. In today's world if exact sizes were not a requirement then you should use the int_fastN_t types to at least guarantee the width you are expecting instead of using the fixed size types (which may not be optimal) or using plain "int" which may be smaller than expected.

Does the standard allow ALL-CAPS headers, or is that just a DOS thing? Because it seems like the C standard always specifies lower-case, but I guess DOS compilers (and, maybe, DOS users) can't distinguish between ALL-CAPS and all-lower.

>This is a deliberate design statement. int was never meant to be "32 bits". It meant "whatever this machine is fastest and most comfortable with".

This is the issue with how people talk about C. int is basically the signed version of size_t aka a word sized data type. It's not meant to have a fixed size.

When people want the classic 4 byte data type they should choose long instead.

  • > int is basically the signed version of size_t aka a word sized data type. It's not meant to have a fixed size.

    It isn't. `int` is at least as large as `short` and at least 16-bits. On modern systems `int` is still typically 32-bits whereas `size_t` is typically 64-bits. There's a `ssize_t` in POSIX for signed sizes.

    > When people want the classic 4 byte data type they should choose long instead.

    `long` is only at least 4 bytes, and at least as large as `int`. On MSVC (LLP64 data model) it's 4 bytes, but on SYSV (LP64 data model) it's 8 bytes. `long` should almost never be used if you actually want portable code today.

    `int` is 32-bits and `long long` is 64-bits on both LP64 and LLP64. If you want portable code using the native integer types, these are the ones you should use, definitely not `long`.

    • Great now you made C look stupid again.

      I am not a C developer but I used to think that there was some sanity in the design. Now that residual sanity I thought was left in C has faded away. I retract all my comments in this HN submission that defend C. Whenever anyone reads them, they should consider them made erroneously in good faith by a person who wanted to justify some of the weird decisions made in C but it turns out they were just plain silly.

      Edit: actually now that I think about I'm willing to extend goodwill to C retroactively if sparkie retracts his silly nitpick and makes it charitable instead.