Skip to content
academia.sh

Lesson 06 / 13

Data Types

A type's value range, the signed–unsigned distinction and integer promotion being decided at the compiler stage; no size is printed except sizeof(char) == 1.

Published Updated

In this lesson

  • Measures and shows that a type's value range is decided at the compiler stage
  • Distinguishes integer promotion from the signed-unsigned conversion with an example
  • Separates unsigned overflow's defined wraparound from signed overflow's undefined behavior
Contents

The Compiler Warnings and Standards lesson measured at which stage a warning arises and what the standard does and does not guarantee: sizeof(char) == 1 was a guarantee, other sizes were not. This lesson builds on that distinction and measures a more fundamental question: at which stage does a type determine the boundary of the values it can carry?

The answer is the compiler. When int is written in a source file, the range that name can carry is fixed neither in the source itself nor at runtime; it is decided the moment the unit is translated, together with the representation chosen for the target. This lesson is not confused with the question measured in M08/K05, “which class path a type must be found on,” nor with the question measured in M09/K05, “the source of a piece of information” — both were axes outside this course; what is measured here is at which stage the value a type carries is decided, and where the boundaries of that decision come from. The Programming Fundamentals course (M01/K02) already established the concepts of variable and type with Python; there, the values a name could hold could change during runtime, and a type’s boundary was never asked separately. C separates these two questions: the type itself carries a boundary, and that boundary is fixed at compile time together with the type name written in the source. Once a variable is declared, the type it carries never changes for the rest of the program; the value may change, but the type that determines which range and which rule the value is interpreted by stays fixed. This lesson measures what that fixedness means through four separate decisions — range, promotion, sign and explicit conversion.

The size of this boundary varies by target, and this variability is deliberate: the language lets the same type name be represented with different widths on different hardware, letting an efficient mapping be built for each target. But this flexibility should not make a lesson’s measurements dependent on that target — if a measurement is true on the machine it runs on here, it must stay true on another target five years from now. For this reason, no real size value is printed in this lesson; instead, the relative relationship between two types is measured. A relationship itself can also vary from target to target, but for a given target the compiler’s decision is fixed, and the same binary reports the same relationship on every run.

Size Unit and Comparative Result

The size of the range a type carries depends on the target and is never printed anywhere in this course — the exception is the one line the standard explicitly guarantees: sizeof(char) is always 1, because the “byte” unit of measure is already defined through char. The measurement below reads this line and the relative size of two types as a logical outcome — larger or smaller, 1/0; no real size value is printed.

// stage.c — the type carrying a value being decided at the compiler stage
#include <stdio.h>
#include <limits.h>

enum Color { RED = 1, GREEN = 2, BLUE = 4 };

static void observe(const char *name, int value) {
    printf("%-40s%d\n", name, value);
}

int main(void) {
    observe("type size unit", (int)sizeof(char));
    observe("enumeration constant", (int)BLUE);
    observe("is int wider than char", (int)(sizeof(int) > sizeof(char)));
    observe("is long wider than int", (int)(sizeof(long) > sizeof(int)));
    observe("is short narrower than int", (int)(sizeof(short) < sizeof(int)));

    char small_a = 100, small_b = 100;
    int promoted_sum = small_a + small_b;
    observe("promoted sum", promoted_sum);

    unsigned char wrapped = UCHAR_MAX;
    wrapped = (unsigned char)(wrapped + 1);
    observe("unsigned wraparound result", wrapped);

    int negative_one = -1;
    unsigned int threshold = 1;
    observe("is it less after converting to unsigned", (int)((unsigned int)negative_one < threshold));

    return 0;
}
type size unit                          1
enumeration constant                    4
is int wider than char                  1
is long wider than int                  1
is short narrower than int              1
promoted sum                            200
unsigned wraparound result              0
is it less after converting to unsigned 0

The type size unit line confirms the standard’s guarantee: char is always a single byte, and this will remain the only size value this course measures. The remaining three comparison lines do not give a real number at all, only the result of a relationship — they ask whether int is wider than char on the target, whether long is wider than int, whether short is narrower than int, and all three come back with the answer 1 (true). BS1. These comparisons themselves are also decided at the compiler stage: each relationship can vary from target to target, but it is fixed while a given unit is being translated and does not change during a run — the same binary produces the same 1 or 0 value on every run.

The standard also already guarantees the relative order of these types: the width between char, short, int, long and long long can never run backward — each one is wide enough to carry every value the one before it could carry. This is not a measurement but the language’s own specification; this lesson only measures whether this order turns out strict or equal on a particular target (here all three came out strict). On some targets int and long may be the same width — the standard only guarantees that long is not narrower than int, not that it is definitely wider. The value 1 measured in this lesson is therefore an observation specific to this target, not a promise the language itself makes.

Integer Promotion

The enumeration constant line is a slice of the enum Color constant introduced in the Compilation Stages lesson: the value of BLUE, 4, is produced at compile time from the constant expression written in the source. In the same measurement, the promoted sum line shows a different decision: small_a and small_b are each a char, but their sum is computed not within char’s bound but within int’s. The C standard promotes narrow types like char and short to int before they enter an operation — this is called integer promotion. If 100 + 100 were carried out on a char (typically in the range -128–127), the result would not fit; but because the operation runs at int width, 200 comes out without any trouble. This promotion is invisible in the source — no conversion was written — but the compiler moves every char operand to int before the operation, and this decision comes entirely from the types, not from the data.

BS2. The promotion rule applies regardless of what the operand values actually are: even if small_a and small_b were 10 and 20, the sum would still be computed at int width, even though the result would stay within char‘s bound. Before running the operation, the compiler decides which width it will run at by looking only at the operands’ types; this is the first instance of the principle that operators behave according to type, not data, and it will be developed further in the Operators lesson.

Promotion is not limited to arithmetic operators; when a function takes a variable number of arguments (printf is the standard library’s example of this), char and short arguments are also promoted to int before the call. This is called the default argument promotion, and it works only for arguments falling into the ... part — after the prototype’s last declared parameter. observe is different: it is a function with a prototype and a fixed signature, so even though the wrapped variable is unsigned char, its value is carried to int by an ordinary assignment conversion because the second parameter is declared int; the %d in observe’s body matches this declared type as well. The two mechanisms are separate: one works only at the tail of variable-argument calls, the other works on every prototyped call. Printing a narrow type with a directly mismatched format specifier (using %d instead of %hhu for unsigned char, for instance) works on printf’s side in most cases thanks to default promotion, but this is a fragile match, and the subject of format strings will be taken up separately in the Input and Output lesson.

Narrowing with an Explicit Conversion

Promotion is an implicit rule — it works without leaving any trace in the source. But a source writer may also want the exact opposite: to deliberately fit a wide value into a narrow type. This is done with an explicit conversion (cast), and the decision itself is again made at compile time, at the point where the type’s name is written.

// narrowing.c — an explicit conversion makes the narrowing visible in the source
#include <stdio.h>

static void observe(const char *name, int value) {
    printf("%-34s%d\n", name, value);
}

int main(void) {
    int wide_value = 300;
    unsigned char narrowed = (unsigned char)wide_value;
    observe("explicitly narrowed value", narrowed);

    int second_value = 600;
    unsigned char second_narrowed = (unsigned char)second_value;
    observe("second explicitly narrowed value", second_narrowed);
    return 0;
}
explicitly narrowed value         44
second explicitly narrowed value  88

The value 300 does not fit into an eight-bit unsigned type, but the conversion is still defined: the same wraparound rule applies here too, giving 300 mod 256 = 44. The same rule applies for 600, giving 88. This is not a different event from the overflow in the previous section, but the same rule triggered explicitly and deliberately in the source. Implicit promotion was invisible in the source; an explicit conversion is visible through the (unsigned char) notation — a reader can tell from the source that a narrowing is intentional. In both cases, which rule applies is determined at compile time by looking at the target type’s sign, not the operand’s value.

The Signed–Unsigned Distinction and Defined Wraparound

Whether a type is signed or unsigned determines where the range it carries starts, and this too is a property decided at compile time. The unsigned wraparound result line measures a variable of type unsigned char being incremented once after it reaches its upper bound (UCHAR_MAX): the result is 0. This is not an error — the standard makes overflow defined for unsigned types: the result “wraps” by taking the modulus against the number of values the type can represent. 255 + 1, on an eight-bit unsigned type, gives 256 mod 256 = 0, and this produces the same result on every run.

The is it less after converting to unsigned line shows another face of the signed–unsigned distinction: a signed integer with the value -1 is converted to unsigned before being compared with an unsigned type. This conversion is also defined — when a negative value is converted to unsigned, the number of values the type can represent is added, moving it to a large unsigned number. As a result, -1 appears not smaller than the 1 in the threshold variable, but far larger, and the comparison returns 0 (false). The signs written in the source are reconciled this way by the compiler during comparison; which side gets converted to which type is determined by looking at the two operands’ declared types, not the data.

This reconciliation rule also explains why unsigned types are so often preferred for carrying counters and sizes: the standard library’s sizeof operator returns size_t, and this type is unsigned because a negative size is conceptually meaningless. But the same choice also carries a trap. Writing a loop bound backward with an unsigned counter — trying to bring a counter down below 0 — never drops the counter into the negatives, because it is unsigned; it wraps from the upper bound instead, and the loop condition stays true forever. This situation is not undefined; the wraparound rule works exactly as seen in the measurement above. But because it would lead to an infinite loop, this lesson does not run this scenario, and only notes how unsigned wraparound can turn into an unexpected result at a boundary condition — the behavior itself is defined, interpreting the result is the source writer’s job.

The Bounding Measurement

Overflow behavior depends on a type’s sign, and this dependency is not symmetric. For unsigned types, overflow is defined as measured above, and wraparound always follows the same rule. For a signed type, overflow is undefined behavior: incrementing an int at the value INT_MAX drops into a state the standard gives no guarantee about — because the compiler does not assume this situation can occur, optimizations may produce unexpected results, the program may crash, or there may be no visible effect at all. Under this course’s general rule, this behavior is not tried in a code block.

The defined counterpart set alongside it is the unsigned char measurement above: the same “crossing one past the upper bound” scenario, built with an unsigned type, gives a fully predictable result. Both scenarios ask the same question — what happens when a type’s boundary is crossed — but the answer depends solely on the sign itself: an arithmetic rule for unsigned, a gap for signed. Choosing a type as signed or unsigned is therefore a decision that determines not only which values can be represented, but also which category the program falls into when the boundary is crossed, and this decision is made entirely at compile time, together with the type name written in the source.

The source of this asymmetry lies in how the two representations correspond in hardware. An unsigned type reads its bits directly as a number’s binary representation; wraparound is a natural consequence of that representation’s limited width, and it works the same way on every target. In a signed type, the representation of negative values can be target-dependent, and guaranteeing which result would arise on overflow for every representation would have restricted certain optimizations the compiler could make for values near the upper bound. For this reason, the language chose to leave signed overflow undefined rather than defined — instead of fixing the behavior, it granted the compiler the freedom to work under the assumption that “this will never happen.”

The practical consequence of this asymmetry is that choosing between a signed and an unsigned type requires asking not only “which values are needed” but also “what should happen if the boundary is crossed.” If a counter will never be negative, an unsigned type both wins an extra bit of range at the upper bound and reduces overflow to a defined wraparound. If a computation can temporarily drop negative in intermediate steps, a signed type carries that transition naturally, but sits closer to undefined behavior in a sum approaching the upper bound. The compiler does not make this choice in place of the source writer; it only reads the type name written and fixes the boundary that name carries and the rule for crossing it.

Summary

  • A type’s value range is decided at the compiler stage; in this course no size value is printed except sizeof(char) == 1, only comparison results are measured.
  • Integer promotion carries narrow types like char and short to int before an operation; this decision is made by looking at the operand types, not the data being carried.
  • Overflow in unsigned types is defined and wraps according to the type’s number of values; overflow in signed types is undefined and is never run in any code block in this course.
  • Converting a signed value to unsigned is also defined: a negative value is moved to a large unsigned number by adding the type’s number of values.
  • Enumeration constants (like BLUE inside enum Color) are produced at compile time from constant expressions in the source; this subject will be developed further in the Structs, Unions and Enumerations lesson.
  • An explicit conversion (like (unsigned char)), unlike implicit promotion, is visible in the source and is subject to the same wraparound rule; which rule applies is again determined at compile time by looking at the target type’s sign.

Next Step

Every decision measured in this lesson stayed within the boundary of a single unit: a type’s range, an operation’s width, a conversion’s direction — all decisions the compiler alone could make by looking at the same file. The types measured in this lesson were already bound to existing names; the next question concerns where that name itself comes from and how long it lives. Which declaration a name is bound to and how long a value lives are not always decided at the same stage. The Variables, Scope and Storage Duration lesson separates these two: where a name is visible from is fixed at compile time, while how long a value lives can only be observed at runtime — and the difference between the two will also raise a third stage, one in which the same name can fall into two separate objects in two separate units.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close