A followup of sorts to the previous post on integer types. Let’s start with a little quiz of sorts. What does this function print?
igor and larry
Answer is... it depends. How big is int and how big long? (i.e., are you running on a 64-bit platform?) On i386, it prints 0. On amd64, it prints 1.
just the usual
The difference comes from the fact that the comparison is either performed signed or unsigned. In the signed case, it’s easy to see that -1 < 1 is true. In the unsigned case, -1 turns into 0xffffffff, and 0xffffffff < 1 is clearly false. What does the size of long have to do with this?
Now would be a good time to read the C standard, but the summary is that when comparing two different integer types, a series of conversions (promotions) are performed until both operands are the same type. Smaller types become larger types, and signed types may become unsigned types, but the order of these operations is very important. I’ll describe what happens on the two platforms.
On i386, igor the unsigned int is turned into a long. With both ints and longs being 32-bits, the full range of unsigned int cannot be represented by signed long, so unsigned long is selected. igor is now an unsigned long. larry is a long, but we are comparing it with unsigned long, so larry also gets promoted to unsigned long. 0xffffffff < 1 == 0.
On amd64, we start with a similar path. igor gets promoted to long. But now long is 64-bits, which is capable of storing the full range of unsigned int. So igor is now a signed long. larry is also a signed long, no further promotion is necessary, and we perform the comparison. -1 < 1 == 1.
For the complete reference, see the C Standard, Section 6.3 Conversions. The phrase used in the standard is “usual arithmetic conversion”. There are more of them than what I’ve outlined here, using terminology like “integer conversion rank” to describe each integer type. For example, what if you compare two short variables? They turn into ints, no matter what. Everything goes to int, then we start applying the rules above.
When do you need to know these rules? I might argue never. My first thought would be that you should consider writing your program such that you never compare different types. After all, if two variables’ precisions are different, that implies at some point one of them may overflow. Otherwise you could use the smaller type for both.
lint will complain, loudly, about signed vs unsigned comparisons. It’s a lot of noise, mostly because various APIs that people use contain a mix of signed and unsigned types, not to mention legacy APIs that accidentally used long instead of specifying a better type (ftell vs ftello). My suggested fix would be to work with only one type (preferably unsigned) and do the conversion at the point where you call the function instead of letting a mix of types flow through the program. Assignment and equality operators (=, ==) are a lot less surprising than the relational operators (<, >).
Try using unsigned. It’s less surprising in many cases. As one example, overflow is only defined for unsigned. Meaning gcc will quite possibly just up and delete code that relies on signed overflow to work. This is despite the fact that on most machines signed overflow works exactly like unsigned overflow. (I think this is a disastrous optimization to make. It’s a security hole waiting to happen. While technically correct, just because the C standard allows a compiler to perform an optimization, the compiler is not required to perform that optimization.) Sticking to unsigned keeps you safely in the bounds of defined behavior and less likely to trigger very subtle undefined behaviors for code that looks correct.
Note that assignment and equality work well with a mix of signed and unsigned. If your only need for a signed integer is to store -1 as an error code (as is the case with a lot of code), remember that you can safely store -1 in an unsigned integer as well.
You do have to be careful here and not take the shortcut of comparing < 0. I always prefer direct comparison with -1 because it makes clear that there is a single return value we are interested in.
the ssize_t disaster
Look at the prototypes for the read and write functions for an example of doing it wrong.
The nbytes argument is unsigned, but the return value is signed. What happens if you write a very large amount of data? write will return a negative number! Maybe nbytes could have been made signed too, but that unnecessarily restricts the range of input values and creates a new failure case: negative inputs. There would have been nothing wrong with returning size_t and using (size_t)-1 to indicate failure.
(On most architectures it will be difficult bordering on impossible to actually overflow ssize_t, but it’s quite possible to do so with writev. The point is that the API is needlessly asymmetrical.)
(Side note: Although (size_t)-1 is equal to SIZE_MAX and thus we do lose one possible return value to act as sentinel, I think that’s better than sacrificing half the range of values. A common problem when we try to signal errors in band with data values. Compare write with mmap which returns MAP_FAILED (-1) to indicate failure when the return value is otherwise a pointer, commonly printed as an unsigned hexadecimal value. We don’t discard half the address space by returning some sort of signed pointer. There’s a single sentinel value. (The confusion between NULL and MAP_FAILED is another example of mixing error codes and data, but now my side note has a side note, and I’m getting off track. Interface design is hard.) Technically, write does have only a single sentinel value, -1, but by using a signed return type, it effectively marks the entire negative range as invalid.)