One Family, Two Questions

Chapter 1 leaned on a single claim: bytes carry no label saying what they represent, so the type is what decides how many bytes to reserve and how to read them. You took that on trust with int and double. But int is not the whole-number type in C. It is one member of a family, and the members differ in exactly the two ways that claim named: how many bytes they span, and how those bytes are interpreted.

Start with the widths, measured with lesson 2's tool:

#include <stdint.h>
#include <stdio.h>

int main(void)
{
    printf("char %zu, short %zu, int %zu, long %zu, long long %zu\n",
           sizeof(char), sizeof(short), sizeof(int), sizeof(long), sizeof(long long));
    printf("int32_t %zu, uint8_t %zu, int64_t %zu\n",
           sizeof(int32_t), sizeof(uint8_t), sizeof(int64_t));

    return 0;
}
char 1, short 2, int 4, long 8, long long 8
int32_t 4, uint8_t 1, int64_t 8

Read the first line as a fact about this platform, not about C. What the standard guarantees is minimum ranges: a char holds at least 8 bits, a short and an int at least 16, a long at least 32, and a long long at least 64. An implementation may give more, and may give two members the same size, which is what happened above. On a different compiler a long is 4 bytes. Wider costs memory and buys range, and int is the default because it is the size the machine handles most comfortably.

Sometimes a minimum is not good enough, because a file format, a network packet, or a hardware register specifies a field as exactly 32 bits and "at least 16" is not an answer. That is what <stdint.h> is for, and its names say what they are: int32_t is a signed 32-bit integer, uint8_t an unsigned 8-bit one, int64_t a signed 64-bit one. Reach for them when the width is genuinely part of the requirement, and stay with plain int when it is not, which is most of the time. One caveat to note and move past: %d and %lld name plain types, while int32_t is only another name for whichever type happens to be 32 bits here, so printing these portably has machinery of its own in <inttypes.h> that the course returns to when you need it.

char Is a Small Integer

char is the member whose name misleads. It holds a character code, and 'A' in single quotes is a character constant whose value is that code. But a char is an integer type like the rest of the family, so you can print it either way: %c shows the value as a character, %d shows the number.

#include <stdio.h>

int main(void)
{
    char letter = 'A';
    unsigned char a = 200;
    unsigned char narrow_sum = a + 100;

    printf("letter %c is the number %d, in %zu byte\n", letter, letter, sizeof letter);
    printf("200 + 100 is %d, stored in an unsigned char it is %d\n", a + 100, narrow_sum);

    return 0;
}
letter A is the number 65, in 1 byte
200 + 100 is 300, stored in an unsigned char it is 44

The 65 is not a translation of the A. It is what sat in that byte all along, and nothing changed between those two readings except the specifier you handed printf. The second line shows a rule that catches everyone: integer promotion. An operand of a type narrower than int is converted to int before arithmetic happens, so 200 plus 100 was computed at int width and 300 is the honest answer, even though 300 is far outside an unsigned char's range of 0 to 255. The truncation to 44 happened one line earlier, on the assignment into a one-byte variable. Narrow types are storage, not arithmetic.

One more detail that reads like trivia and is not. Plain char may be signed or unsigned, and which one it is is implementation-defined. That term is precise, and worth separating from two neighbours: undefined means the standard imposes no requirement at all, as with reading an uninitialized variable in lesson 2; unspecified means the implementation chooses among allowed behaviours and need not tell you which; implementation-defined means it chooses and must document the choice. So your compiler has a definite, written-down answer about char, and a compiler on another machine may have the opposite one. Stop leaving the question open: plain char for text, and signed char or unsigned char when you want a byte as a number.

signed and unsigned

Every integer type comes in both flavours. A signed type spends part of its range on negatives; an unsigned type has none and spends the whole range climbing. Same width, same bytes, different reading. <limits.h> names the boundaries so you need not memorize them:

#include <limits.h>
#include <stdio.h>

int main(void)
{
    int negative = -1;
    unsigned int same_bits = negative;

    printf("int runs from %d to %d\n", INT_MIN, INT_MAX);
    printf("unsigned int runs from 0 to %u\n", UINT_MAX);
    printf("%d read as an unsigned int is %u\n", negative, same_bits);

    return 0;
}
int runs from -2147483648 to 2147483647
unsigned int runs from 0 to 4294967295
-1 read as an unsigned int is 4294967295

%u is the specifier for an unsigned int and %d will not stand in for it. That last line is the lesson in miniature: four bytes, two answers. Converting -1 to unsigned int is fully defined, and the standard defines it arithmetically, by adding one more than the maximum until the value is in range. On a machine like this one it is also a pure reinterpretation, the identical 32 bits spelling -1 as signed and 4294967295 as unsigned. The bits did not move; "what is stored here" simply has two answers, and the type picks one.

That is also why mixing the two in one expression is a trap. The next program is wrong.

#include <stdio.h>

int main(void)
{
    int owed = -1;
    unsigned int budget = 1;
    int is_less = owed < budget;

    printf("owed < budget is %d\n", is_less);

    return 0;
}

When a comparison has one signed and one unsigned operand of the same width, the signed one is converted to unsigned. By the rule you just watched, the -1 becomes 4294967295, which is not less than 1, so the program prints owed < budget is 0 where every human reader expects a 1. -Wextra is watching:

compare.c: In function 'main':
compare.c:7:24: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]
    7 |     int is_less = owed < budget;
      |                        ^

Fix it by giving both sides the same signedness, here by declaring budget an int. And choose unsigned deliberately: for bit patterns and size_t-shaped counts, never merely because a value "cannot be negative". That reasoning converts a diagnosable negative number into an enormous positive one.

When int Meets double

The family mixes with double too, in both directions:

#include <stdio.h>

int main(void)
{
    int total = 7;
    int parts = 2;
    double measured = 3.9;
    int whole = measured;

    printf("7 / 2 is %d, but 7 / 2.0 is %.1f\n", 7 / 2, 7 / 2.0);
    printf("(double)total / parts is %.2f\n", (double)total / parts);
    printf("3.9 stored in an int is %d\n", whole);

    return 0;
}
7 / 2 is 3, but 7 / 2.0 is 3.5
(double)total / parts is 3.50
3.9 stored in an int is 3

7 / 2 has two int operands, so it is an integer division, and integer division truncates toward zero: it discards the fractional part rather than rounding, so 3.5 becomes 3, and -3.5 would become -3 rather than -4. Write 2.0 and one operand is a double, so the other is converted to match and you get a real division. That .0 is the whole difference between those two answers, and it is the most common way a calculation quietly loses its fraction. Storing a double into an int truncates toward zero as well, 3.9 to 3. None of it earns a warning, because these conversions are legal and usually intended, which is exactly why the unintended ones are so quiet.

(double)total is a cast: a type name in parentheses meaning "convert this value to that type, explicitly". Casting one operand is enough, since the other is then converted to match. Watch where the parentheses stop, though: (double)(total / parts) does the integer division first and converts the 3, arriving too late to save anything. Treat casts as a last resort, because a cast you find yourself needing is usually a sign that something upstream has the wrong type, and then the declaration is the thing to fix. This one is legitimate: total and parts really are counts, and only this one calculation wants a real answer.

The Overflow Asymmetry

What happens when a result will not fit? The answer depends on signedness, and the gap between the two answers is wider than anything else in this lesson. Unsigned arithmetic wraps, and the standard says so: results are reduced modulo one more than the largest representable value. Signed arithmetic makes no such promise. The next program is wrong, and its output is deliberately not shown:

#include <limits.h>
#include <stdio.h>

int main(void)
{
    int at_top = INT_MAX;
    int overflowed = at_top + 1;

    printf("INT_MAX + 1 is %d\n", overflowed);

    return 0;
}

Signed integer overflow is undefined behaviour. Not "it wraps to a negative number", not "it gives a strange result": the standard imposes no requirement on the program whatsoever, so printing what it happened to produce would be presenting a non-answer as a fact.

Here is the part that catches people. That program compiles without a single warning, runs to completion, exits normally, and prints something shaped like an answer. This platform's AddressSanitizer, which reported the missing & in lesson 4 as a WRITE memory access, sees nothing at all: ASan detects memory errors, not arithmetic ones, so signed overflow passes straight through it. A clean run proves nothing here. And because the compiler is entitled to assume the overflow never happens, it may optimize the surrounding code on that assumption, so the damage need not surface anywhere near the line you wrote.

Both halves of the asymmetry, done in types that can hold their results:

#include <limits.h>
#include <stdio.h>

int main(void)
{
    long long wide = INT_MAX;
    unsigned int at_top = UINT_MAX;

    printf("INT_MAX + 1 as a long long is %lld\n", wide + 1);
    printf("UINT_MAX + 1 as an unsigned int is %u\n", at_top + 1);

    return 0;
}
INT_MAX + 1 as a long long is 2147483648
UINT_MAX + 1 as an unsigned int is 0

Two defined results. The addition now happens at 64-bit width where 2147483648 fits comfortably, and the unsigned count rolls over to 0 exactly as the standard requires, identically on every conforming implementation. Choosing a type wide enough for every result it will hold is the everyday form of this fix. The other form is to check that an operation is safe before performing it, which needs a way to send the program down one path or another, and that arrives later in this chapter.

Key Takeaways

  • The integer types are a family differing in width and signedness. C guarantees only minimum ranges, at least 8 bits for char, 16 for short and int, 32 for long, 64 for long long. Widths from a sizeof run are facts about the platform, and <stdint.h> provides exact widths such as int32_t, uint8_t, and int64_t for when the width is genuinely part of the requirement.
  • char is a small integer holding a character code. 'A' is a character constant whose value is that code, printed as a character with %c and as a number with %d.
  • Plain char is signed or unsigned as the implementation chooses, which is implementation-defined: it chooses and must document. That differs from unspecified, where it need not document, and from undefined, where nothing at all is required.
  • Operands narrower than int are promoted to int before arithmetic, so unsigned char 200 plus 100 is 300, not 44. Truncation happens on assignment back into the narrow type.
  • Signed and unsigned are the same bytes read two ways. <limits.h> gives INT_MIN, INT_MAX, and UINT_MAX, and an unsigned int prints with %u.
  • Comparing signed with unsigned converts the signed operand to unsigned, making owed < budget false for -1 against 1. -Wextra reports it as -Wsign-compare. Keep both sides the same signedness.
  • Integer division truncates toward zero: 7 / 2 is 3 while 7 / 2.0 is 3.5. Storing a double into an int truncates the same way, and neither is warned about. A cast such as (double)total / parts converts explicitly, and is a last resort rather than a habit.
  • Unsigned overflow wraps and is fully defined. Signed overflow is undefined behaviour, produces no warning, and is invisible to AddressSanitizer, which catches memory errors and not arithmetic ones. Compute in a type wide enough to hold the result.