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
charandshorttointbefore 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
BLUEinsideenum 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.