2. Types, Values, and Expressions
This chapter covers C's type system — what a value is at the bit level, how you write one down, what the operators do to it, and the conversion rules the compiler silently applies between them. C's types map almost directly onto hardware registers, which makes them fast and predictable, but also means the language will happily let a value overflow, truncate, or change sign without saying a word. Most subtle C bugs live in exactly this territory, so learning the conversion and promotion rules precisely is the highest-leverage investment you can make in the language.
<a id="21-fundamental-types"></a>
2.1 Fundamental Types
C's built-in types are a thin veneer over machine storage: integers of various widths, floating-point numbers, and a deliberate absence of a type. This section covers what each one guarantees — and, more importantly, what it does not.
char, short, int, long, long long
Theory
C does not specify how big an int is. This surprises everyone at first, but it follows directly from C's design goal: run efficiently on wildly different hardware. On a 16-bit microcontroller, forcing int to be 32 bits would make ordinary arithmetic slow. So the standard specifies only minimum ranges, and each implementation picks sizes that suit its target.
What the standard actually guarantees:
| Type | Minimum bits | Guaranteed minimum range |
|---|---|---|
char | 8 (CHAR_BIT) | -127..127 or 0..255 |
short | 16 | -32767..32767 |
int | 16 | -32767..32767 |
long | 32 | -2147483647..2147483647 |
long long (C99) | 64 | -9223372036854775807..+same |
Plus one ordering rule you can always rely on:
sizeof(char) == 1 <= sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)
Note the ranges are symmetric around zero (-32767, not -32768). The standard historically permitted sign-magnitude and ones' complement representations, so it could not promise the extra negative value. C23 finally mandates two's complement, making INT_MIN == -32768 (for 16-bit int) guaranteed — but portable code written for older standards still uses the symmetric minimums.
In practice, real systems cluster into a few data models:
| Model | int | long | pointer | Where |
|---|---|---|---|---|
| ILP32 | 32 | 32 | 32 | 32-bit Linux, 32-bit Windows |
| LP64 | 32 | 64 | 64 | 64-bit Linux, macOS, BSD |
| LLP64 | 32 | 32 | 64 | 64-bit Windows |
The trap is immediate: long is 64 bits on Linux and 32 bits on Windows, both 64-bit systems. Code that stores a pointer in a long works on one and corrupts on the other. int is 32 bits essentially everywhere on desktop/server hardware, which is why it remains the default for loop counters and small values.
Think of these types as "at least this big, chosen to be fast on this machine." When you need an exact width, use stdint.h (covered later in this subchapter).
Examples
#include <stdio.h>
#include <limits.h> /* CHAR_BIT, INT_MAX, LONG_MIN, ... */
int main(void)
{
printf("CHAR_BIT = %d bits per byte\n", CHAR_BIT);
/* %zu is the correct specifier for size_t, which sizeof yields. */
printf("char %zu bytes\n", sizeof(char));
printf("short %zu bytes\n", sizeof(short));
printf("int %zu bytes\n", sizeof(int));
printf("long %zu bytes\n", sizeof(long));
printf("long long %zu bytes\n", sizeof(long long));
printf("\nINT_MIN = %d\n", INT_MIN);
printf("INT_MAX = %d\n", INT_MAX);
printf("LONG_MAX = %ld\n", LONG_MAX); /* %ld for long */
printf("LLONG_MAX = %lld\n", LLONG_MAX); /* %lld for long long */
return 0;
}
Typical LP64 output:
CHAR_BIT = 8 bits per byte
char 1 bytes
short 2 bytes
int 4 bytes
long 8 bytes
long long 8 bytes
Bit layout of a 32-bit int holding the value 5:
bit 31 bit 0
| |
v v
0000 0000 0000 0000 0000 0000 0000 0101 = 5
^
sign bit (0 = non-negative)
1111 1111 1111 1111 1111 1111 1111 1011 = -5 (two's complement)
^
sign bit (1 = negative)
Key Takeaways
- C specifies minimum ranges, not exact sizes;
intis 16 bits minimum but 32 bits in practice on modern hardware. - The guaranteed ordering is
char <= short <= int <= long <= long long, withsizeof(char) == 1by definition. longis 64-bit on Linux/macOS (LP64) but 32-bit on Windows (LLP64) — never assume it holds a pointer.limits.hgives you the actual bounds (INT_MAX,LONG_MIN,CHAR_BIT) for the current implementation.- Use
%zuforsize_t,%ldforlong,%lldforlong long; mismatched specifiers are undefined behavior.
🧪 Practice
- Write a program that prints the size and the
_MIN/_MAXvalues of every integer type on your machine. Identify which data model you are on.- Compute
INT_MAX + 1and print it. Then computeUINT_MAX + 1uand print it. Explain why only one of these is well-defined.- Write a function
int fits_in_short(long v)that returns 1 ifvis representable in ashort, usingSHRT_MIN/SHRT_MAXrather than hardcoded numbers.- Interview-style: "You need a variable that can hold any pointer as an integer. Which type do you use?" Hint: neither
intnorlongis portable — look instdint.h.
Signed vs Unsigned
Theory
Every integer type has a signed and an unsigned flavor. The distinction is not just "can it be negative" — it changes the arithmetic rules, and mixing the two is one of the most productive sources of C bugs.
An N-bit unsigned type stores values 0 .. 2^N - 1. All bits are magnitude. A signed type spends its top bit on sign, giving -2^(N-1) .. 2^(N-1) - 1 in two's complement.
8-bit patterns, two ways of reading the same bits:
bits unsigned char signed char
0000 0000 0 0
0111 1111 127 127
1000 0000 128 -128 <-- same bits, different value
1111 1111 255 -1 <-- same bits, different value
The critical behavioral difference:
| Signed | Unsigned | |
|---|---|---|
| Overflow | Undefined behavior | Wraps modulo 2^N (well-defined) |
x < 0 possible | Yes | Never — the comparison is always false |
| Right shift of negative | Implementation-defined | N/A |
| Division truncation | Toward zero | Toward zero (no negatives) |
Unsigned wraparound being defined is genuinely useful (hashing, checksums, ring buffers). Signed overflow being undefined is what lets the optimizer assume x + 1 > x, which enables loop optimizations — and which silently deletes your overflow checks.
The classic failure mode is a comparison between signed and unsigned. The usual arithmetic conversions (covered in 2.4) convert the signed operand to unsigned, so a negative number becomes enormous:
int i = -1;
unsigned u = 1;
if (i < u) /* i converts to UINT_MAX = 4294967295 */
puts("less"); /* NEVER PRINTS */
else
puts("not less"); /* this runs */
A special case worth memorizing: plain char is a distinct third type. char, signed char, and unsigned char are three different types, and whether plain char is signed is implementation-defined (signed on x86 Linux, unsigned on ARM Linux). This is why getchar() returns int, not char — it must distinguish 256 byte values from EOF (-1).
Examples
#include <stdio.h>
#include <limits.h>
#include <string.h>
int main(void)
{
/* 1. Unsigned wraparound is defined and predictable. */
unsigned char u = 255;
u++; /* wraps to 0, modulo 256 */
printf("255 + 1 (unsigned char) = %u\n", u); /* 0 */
/* 2. The signed/unsigned comparison trap. */
int i = -1;
unsigned int ui = 1;
printf("(-1 < 1u) = %d\n", i < ui); /* 0 -- not what you expect */
/* 3. The classic loop that never terminates. */
for (unsigned k = 3; k >= 0; k--) { /* k >= 0 is ALWAYS true */
if (k > 100) break; /* escape hatch so we can run this */
printf("k = %u\n", k); /* 3 2 1 0 then wraps to 4294967295 */
}
/* 4. Plain char signedness is implementation-defined. */
char c = 200; /* may be -56 or 200 */
printf("char 200 -> %d\n", c);
/* 5. Why getchar returns int: EOF must be distinguishable. */
printf("EOF = %d\n", EOF); /* -1, not representable in unsigned char */
return 0;
}
Safe patterns:
/* Compare sizes without mixing signedness: make both unsigned. */
size_t len = strlen(s);
if (len > (size_t)max_allowed) { /* ... */ }
/* Or compare in the signed domain, after checking the range fits. */
if (len <= (size_t)INT_MAX && (int)len > max_allowed) { /* ... */ }
/* For byte manipulation, always use unsigned char -- no sign extension. */
unsigned char *bytes = (unsigned char *)data;
Key Takeaways
- Unsigned overflow wraps modulo
2^Nand is fully defined; signed overflow is undefined behavior. - In a mixed signed/unsigned comparison, the signed value is converted to unsigned — negative values become huge.
unsigned x >= 0is always true; a countdown loop with an unsigned counter never terminates.char,signed char, andunsigned charare three distinct types; plainchar's signedness is implementation-defined.- Use
unsigned charfor raw bytes,size_tfor sizes and indices, and enable-Wsign-compare(in-Wextra).
🧪 Practice
- Write the infinite unsigned countdown loop and confirm it wraps. Fix it two different ways (a signed counter, and a
while (k-- > 0)form).- Print the result of
(unsigned char)-1,(char)-1, and(int)(char)200on your machine. Explain each value.- Write a function that safely compares a
size_tagainst anintwithout any implicit conversion warnings under-Wextra -Wconversion.- Interview-style: "Why does
getchar()returnintinstead ofchar?" Hint: count how many distinct values it must be able to return.
float, double, long double
Theory
Integers count; floating-point numbers approximate. A float does not store a real number — it stores a sign, an exponent, and a fixed number of significant bits, giving you scientific notation in binary. The consequence is that most decimal fractions cannot be represented exactly, in the same way 1/3 cannot be written exactly in decimal.
C provides three floating types, which on nearly all modern systems map to IEEE 754 formats:
| Type | Typical size | Format | Significant decimal digits | Approx. range |
|---|---|---|---|---|
float | 4 bytes | IEEE 754 binary32 | ~7 | ±1.2e-38 .. ±3.4e38 |
double | 8 bytes | IEEE 754 binary64 | ~15–17 | ±2.2e-308 .. ±1.8e308 |
long double | 8, 12, or 16 bytes | x87 80-bit, binary128, or same as double | 18–33 | platform-dependent |
long double is the least portable type in C. On x86 Linux/macOS with GCC it is the 80-bit x87 extended format (stored in 12 or 16 bytes for alignment); on MSVC it is identical to double; on some ARM and POWER systems it is true 128-bit quad precision. Do not rely on it for portable code.
double is the default. An unsuffixed literal like 3.14 is a double, math functions like sin take and return double, and older C rules promoted float arguments to double in variadic calls. Use float only when you have a specific reason: large arrays where memory bandwidth dominates, GPU/SIMD work, or embedded targets with only single-precision hardware.
The mental model: floating point trades exactness for range. You get to represent 1e300 and 1e-300, but you give up the guarantee that 0.1 + 0.2 == 0.3.
Examples
#include <stdio.h>
#include <float.h> /* FLT_DIG, DBL_EPSILON, DBL_MAX, ... */
#include <math.h> /* fabs, sqrt, INFINITY, NAN */
int main(void)
{
printf("float %zu bytes, %d significant digits\n",
sizeof(float), FLT_DIG);
printf("double %zu bytes, %d significant digits\n",
sizeof(double), DBL_DIG);
printf("long double %zu bytes, %d significant digits\n",
sizeof(long double), LDBL_DIG);
/* The canonical demonstration: 0.1 is not exactly 0.1 in binary. */
printf("\n0.1 + 0.2 = %.20f\n", 0.1 + 0.2); /* 0.30000000000000004441 */
printf("(0.1 + 0.2 == 0.3) is %d\n", 0.1 + 0.2 == 0.3); /* 0 -- false! */
/* Precision difference between float and double. */
float f = 0.1f;
double d = 0.1;
printf("\nfloat 0.1 = %.20f\n", (double)f); /* 0.10000000149011611938 */
printf("double 0.1 = %.20f\n", d); /* 0.10000000000000000555 */
/* Special values are part of IEEE 754. */
double inf = 1.0 / 0.0; /* +infinity (with IEEE semantics) */
double nan = 0.0 / 0.0; /* NaN */
printf("\ninf = %f, isinf = %d\n", inf, isinf(inf));
printf("nan = %f, isnan = %d\n", nan, isnan(nan));
printf("(nan == nan) = %d\n", nan == nan); /* 0 -- NaN != itself */
return 0;
}
Choosing a type:
/* Default: use double. */
double average(const double *v, size_t n)
{
double sum = 0.0;
for (size_t i = 0; i < n; i++) sum += v[i];
return sum / (double)n; /* cast avoids int/double surprises */
}
/* Use float deliberately: 100 million samples, memory-bandwidth bound. */
float *audio_samples; /* 400 MB as float vs 800 MB as double */
Key Takeaways
float≈ 7 significant decimal digits,double≈ 15–17;long doubleis platform-dependent and non-portable.doubleis the language default: unsuffixed literals andmath.hfunctions aredouble.- Most decimal fractions (including 0.1) have no exact binary representation — never test floats with
==. - IEEE 754 defines
+inf,-inf, andNaN;NaNcompares unequal to everything, including itself. float.hprovidesFLT_DIG,DBL_EPSILON,DBL_MAX, and friends for the current implementation.
🧪 Practice
0.1 + 0.2with%.20fand explain the trailing digits. Then find the smallestnwhere1.0 + 1.0/n == 1.0indouble.- Sum the value
0.1one million times in bothfloatanddouble, and compare each result against100000.0. Quantify the drift.- Write a
bool nearly_equal(double a, double b, double eps)helper and use it to compare0.1 + 0.2with0.3.- Interview-style: "Why should you never use a
floatto store a monetary amount?" Hint: think about what 0.10 looks like in binary and what happens after ten thousand additions.
_Bool and stdbool.h
Theory
C89 had no boolean type. Truth was just "an integer": 0 is false, anything else is true. That works, but it means a function returning "yes" could return 1, 42, or -7, and there was no type-level documentation of intent.
C99 added _Bool, a real type with exactly two values. Its defining property is the conversion rule: assigning any scalar to a _Bool yields 0 if the value is zero (or a null pointer), and 1 otherwise. This normalization is what makes it different from a small int.
_Bool b = 42; /* stores 1, not 42 */
_Bool c = 0.5; /* stores 1 */
_Bool d = 0.0; /* stores 0 */
The ugly _Bool spelling exists for backward compatibility: adding a keyword named bool in 1999 would have broken every codebase that had already defined its own. <stdbool.h> provides the friendly names as macros:
#define bool _Bool
#define true 1
#define false 0
#define __bool_true_false_are_defined 1
In C23, bool, true, and false became real keywords, and <stdbool.h> became unnecessary (it still exists, empty, for compatibility). Note that in C23, true and false have type bool, whereas in C99–C17 they were int constants 1 and 0.
Relational and logical operators (<, ==, &&, !) yield int values 0 or 1 in C, not bool — another difference from C++ that occasionally matters.
Examples
#include <stdio.h>
#include <stdbool.h> /* not needed in C23, harmless to include */
/* Self-documenting signature: the return type says what it means. */
bool is_even(int n)
{
return n % 2 == 0; /* comparison yields int 0/1, converted to bool */
}
int main(void)
{
bool flag = true;
printf("sizeof(bool) = %zu\n", sizeof(bool)); /* usually 1 */
/* The normalizing conversion is the whole point. */
bool a = 42;
bool b = -1;
bool c = 0.0001;
printf("bool from 42 = %d\n", a); /* 1 */
printf("bool from -1 = %d\n", b); /* 1 */
printf("bool from 0.0001 = %d\n", c); /* 1 */
/* Contrast with int, which stores the value as-is. */
int i = 42;
printf("int from 42 = %d\n", i); /* 42 */
/* Idiomatic conditionals: test the value, do not compare to true. */
if (flag) puts("set"); /* good */
if (flag == true) puts("set"); /* redundant; and fragile with int flags */
printf("is_even(7) = %s\n", is_even(7) ? "true" : "false");
return 0;
}
A common pre-C99 pattern you will still encounter, and why it is fragile:
/* Legacy style -- works, but nothing normalizes the value. */
#define TRUE 1
#define FALSE 0
typedef int BOOL;
BOOL result = some_flags & MASK; /* might be 8, not 1 */
if (result == TRUE) { } /* FAILS when result is 8 */
if (result) { } /* correct */
Key Takeaways
_Bool(C99) holds exactly 0 or 1; assigning any nonzero scalar normalizes it to 1.- Include
<stdbool.h>forbool,true,falsein C99–C17; in C23 they are keywords. - Relational and logical operators return
int(0 or 1) in C, notbool. - Never write
if (x == true)— test the value directly, since non-boolflags may hold any nonzero value. - Using
boolas a return type documents intent in a wayintcannot.
🧪 Practice
- Assign several nonzero values (including a float and a pointer) to a
booland print each result. Confirm they all normalize to 1.- Write the fragile
result == TRUEexample with a value of 8 and demonstrate the bug; then fix it.sizeof(bool)andsizeof(_Bool)and confirm they match. Compile with-std=c23without including<stdbool.h>and verify it still works.
void
Theory
void is C's way of saying "no type here." It is an incomplete type that can never be completed, which means you can never create an object of type void — void x; is an error. Its usefulness comes entirely from the three places the language gives it special meaning.
1. No return value. void f(void) returns nothing; using its result is an error.
2. No parameters. int f(void) means "takes exactly zero arguments." As covered in Chapter 1, int f() in C17 and earlier means "unspecified parameters" and disables argument checking. Always write (void).
3. void * — the generic pointer. This is the important one. A void * can point to an object of any type. It has no notion of what it points at, so you cannot dereference it or do pointer arithmetic on it (GCC permits void * arithmetic as an extension, treating the size as 1, but it is not standard C).
Crucially, in C, conversion between void * and any object pointer type is implicit and safe in both directions. This is why malloc returns void * and you do not need a cast:
int *p = malloc(n * sizeof *p); /* void* -> int* implicitly; no cast needed */
Casting the result of malloc is a C++ habit; in C it is unnecessary and can hide a missing #include <stdlib.h>.
The void * type is the foundation of generic programming in C — qsort, memcpy, and every generic container use it as the erased type.
A fourth idiom worth knowing: casting an expression to void explicitly discards it, which documents intent and silences warnings.
(void)unused_parameter; /* "I know, and I mean it" */
(void)printf("..."); /* deliberately ignoring the return value */
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* 1. void return: the function is called for its effect. */
void log_message(const char *msg)
{
fprintf(stderr, "[log] %s\n", msg);
/* 'return;' with no value is legal here; falling off the end is fine. */
}
/* 2. void parameters: takes exactly nothing. */
int get_answer(void) { return 42; }
/* 3. void*: a generic swap that works for any type. */
void generic_swap(void *a, void *b, size_t size)
{
unsigned char tmp[64];
if (size > sizeof tmp) return; /* keep the example simple */
memcpy(tmp, a, size); /* memcpy takes void* -- any type */
memcpy(a, b, size);
memcpy(b, tmp, size);
}
int main(void)
{
int x = 1, y = 2;
generic_swap(&x, &y, sizeof x); /* int* converts to void* implicitly */
printf("x=%d y=%d\n", x, y); /* x=2 y=1 */
double d1 = 1.5, d2 = 2.5;
generic_swap(&d1, &d2, sizeof d1); /* same function, different type */
printf("d1=%g d2=%g\n", d1, d2);
/* void* round-trip is guaranteed to preserve the value. */
int n = 99;
void *v = &n; /* int* -> void* (implicit) */
int *p = v; /* void* -> int* (implicit in C) */
printf("*p = %d\n", *p);
/* Illegal operations on void*, shown as comments: */
/* printf("%d", *v); ERROR: cannot dereference void* */
/* v = v + 1; Not standard C (GCC extension) */
/* void nothing; ERROR: cannot declare an object of type void */
(void)get_answer(); /* deliberately discard the result */
log_message("done");
return 0;
}
Key Takeaways
voidis an incomplete type; you cannot declare an object of typevoid.f(void)means zero parameters — always prefer it tof()in C17 and earlier.void *is the generic object pointer; conversions to and from it are implicit in C (unlike C++).- You cannot dereference a
void *or do standard pointer arithmetic on it — you must cast to a concrete type first. - Do not cast the return value of
mallocin C; the implicit conversion is correct and the cast can mask a missing header.
🧪 Practice
- Write a
void print_array(const void *base, size_t count, size_t elem_size, void (*print)(const void *))that prints any array using a callback.- Try to compile
void x;,*(void *)p, andv + 1wherevisvoid *. Record each diagnostic.- Write a generic
void *find_max(const void *base, size_t n, size_t size, int (*cmp)(const void *, const void *))in the style ofqsort's comparator.- Interview-style: "Why does
mallocreturnvoid *rather thanchar *?" Hint: think about what conversions the caller would otherwise have to write, and about alignment guarantees.
Fixed-Width Types in stdint.h
Theory
Because int and long have implementation-defined sizes, any code that touches a binary format — a network packet, a file header, a hardware register, a serialized struct — cannot use them safely. C99 introduced <stdint.h> to solve exactly this.
The header defines three families, and picking the right family is the actual skill:
| Family | Meaning | Example | Guaranteed to exist? |
|---|---|---|---|
intN_t | Exactly N bits, two's complement, no padding | int32_t | Only if the platform has such a type |
int_leastN_t | Smallest type with at least N bits | int_least16_t | Yes, for N = 8, 16, 32, 64 |
int_fastN_t | Fastest type with at least N bits | int_fast32_t | Yes, for N = 8, 16, 32, 64 |
Each has an uintN_t unsigned counterpart. Plus special-purpose types:
intptr_t/uintptr_t— an integer large enough to hold avoid *(optional, but present everywhere real).intmax_t/uintmax_t— the largest integer type available.size_t(fromstddef.h) — unsigned, holds any object size; the type ofsizeof.ptrdiff_t(fromstddef.h) — signed, the result of subtracting two pointers.
Choosing between them:
- Binary layout matters (file/network/hardware):
int32_t,uint8_t. Exact width or nothing. - You just need a range guarantee:
int_least32_t. Portable to exotic platforms without exact-width types. - Hot loop counter:
int_fast32_t. May be 64 bits if that is faster on the target. - Sizes and array indices:
size_t.
Printing these types requires the format macros in <inttypes.h>, because %d is wrong if int32_t is a long on some platform:
printf("%" PRId32 "\n", value); /* PRId32 expands to "d" or "ld" as needed */
That string-concatenation syntax is ugly but it is the only portable way.
Examples
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h> /* PRId32, PRIu64, SCNd32, ... */
/* A binary file/network header: every field must have an exact, known width. */
typedef struct {
uint32_t magic; /* exactly 4 bytes, always */
uint16_t version; /* exactly 2 bytes */
uint16_t flags;
uint64_t payload_size; /* exactly 8 bytes */
} FileHeader;
int main(void)
{
/* Exact-width: use when the bit pattern is part of a contract. */
uint8_t byte = 255;
int32_t count = -100000;
uint64_t big = 18446744073709551615ULL; /* UINT64_MAX */
printf("byte = %" PRIu8 "\n", byte);
printf("count = %" PRId32 "\n", count);
printf("big = %" PRIu64 "\n", big);
/* Range-guaranteed, portable everywhere. */
int_least16_t small = 30000;
/* Fastest type holding at least 32 bits -- may actually be 64. */
int_fast32_t fast = 1000000;
printf("int_fast32_t is %zu bytes\n", sizeof fast);
/* Pointer-sized integer. */
int v = 5;
uintptr_t addr = (uintptr_t)&v;
printf("address as integer: %" PRIuPTR "\n", addr);
/* Limits come from the same header. */
printf("INT32_MAX = %" PRId32 "\n", INT32_MAX);
printf("UINT8_MAX = %" PRIu8 "\n", UINT8_MAX);
printf("sizeof(FileHeader) = %zu\n", sizeof(FileHeader)); /* 16, if no padding */
return 0;
}
Guidance in table form:
| Use case | Type |
|---|---|
| Byte buffer / raw data | uint8_t (or unsigned char) |
| File or wire protocol field | int32_t, uint16_t, … |
| Hardware register | volatile uint32_t |
Array index, size, sizeof result | size_t |
| Pointer difference | ptrdiff_t |
| Storing a pointer as an integer | uintptr_t |
| Loop counter, no layout constraint | int or int_fast32_t |
Key Takeaways
intN_tis exact-width and only exists where the platform supports it;int_leastN_tandint_fastN_talways exist.- Use exact-width types whenever the bit layout is part of an external contract (files, networks, hardware).
size_tfor sizes and indices,ptrdiff_tfor pointer differences,uintptr_tto hold a pointer as an integer.- Print fixed-width types with
<inttypes.h>macros (PRId32,PRIu64);%dis not portable for them. - Fixed-width types do not fix struct padding — layout still depends on alignment (see Chapter 6).
🧪 Practice
- Rewrite a struct that uses
intandshortfields to use exact-width types, then printsizeoffor both versions and explain any difference.- Write a function that serializes a
uint32_tinto a 4-byte big-endian buffer, and a matching deserializer. Test the round trip.sizeof(int_fast8_t)andsizeof(int_least8_t)on your machine. Explain why they may differ.- Interview-style: "When would you choose
int_fast32_toverint32_t?" Hint: think about which one has a layout obligation and which one is purely about arithmetic speed.
sizeof and Type Sizes
Theory
sizeof answers one question: how many bytes does this object or type occupy? It is an operator, not a function, and it is evaluated at compile time (with one exception). Its result has type size_t, an unsigned type, which is why the correct printf specifier is %zu.
Three properties define its behavior:
1. sizeof(char) == 1 by definition. A "byte" in C is whatever char is — on virtually all systems 8 bits, but the standard only requires CHAR_BIT >= 8. All other sizes are expressed in these units.
2. The operand is not evaluated. sizeof only needs the type of the expression, so side effects never happen:
int i = 0;
size_t n = sizeof(i++); /* i is still 0 -- i++ is never executed */
The single exception is a variable-length array, whose size genuinely depends on runtime values.
3. Parentheses are required for types, optional for expressions.
sizeof(int) /* type: parentheses REQUIRED */
sizeof x /* expression: parentheses optional */
sizeof(x) /* also fine */
The most valuable idiom in C uses form two:
int *p = malloc(n * sizeof *p); /* size of what p points to */
Written this way, changing p's type does not require editing the malloc call — the size follows automatically. Compare with malloc(n * sizeof(int)), which silently becomes wrong if p becomes a long *.
The other essential idiom is array length:
size_t count = sizeof arr / sizeof arr[0];
This works only where arr is a real array. Pass an array to a function and it decays to a pointer, at which point sizeof arr gives the pointer size and the idiom silently produces garbage. This is one of the most common bugs in beginner C.
Examples
#include <stdio.h>
#include <stdlib.h>
/* The array has DECAYED to a pointer here -- sizeof is wrong inside. */
void broken_count(int arr[])
{
/* sizeof arr is sizeof(int*) = 8, sizeof arr[0] = 4 -> 2 */
printf(" inside function: %zu (WRONG)\n", sizeof arr / sizeof arr[0]);
}
/* Correct: pass the length explicitly. */
void correct_count(const int *arr, size_t n)
{
(void)arr;
printf(" inside function: %zu (correct)\n", n);
}
int main(void)
{
int arr[10];
size_t n = sizeof arr / sizeof arr[0];
printf("in main: %zu\n", n); /* 10 -- correct */
broken_count(arr);
correct_count(arr, n);
/* sizeof does not evaluate its operand. */
int i = 0;
size_t s = sizeof(i++);
printf("\nsizeof(i++) = %zu, i is still %d\n", s, i); /* i == 0 */
/* The self-maintaining malloc idiom. */
double *d = malloc(20 * sizeof *d); /* == 20 * sizeof(double) */
if (d == NULL) return 1;
printf("allocated %zu bytes\n", 20 * sizeof *d);
free(d);
/* Structs include padding -- sizeof is not the sum of member sizes. */
struct S { char c; int i; }; /* 1 + 4 = 5 bytes of data ... */
printf("sizeof(struct S) = %zu\n", sizeof(struct S)); /* ... but 8 */
/* Common sizes on a typical LP64 machine. */
printf("\nsizeof(char) = %zu\n", sizeof(char)); /* 1, always */
printf("sizeof(int) = %zu\n", sizeof(int)); /* 4 */
printf("sizeof(void*) = %zu\n", sizeof(void *)); /* 8 */
printf("sizeof(size_t) = %zu\n", sizeof(size_t)); /* 8 */
return 0;
}
Why the struct is 8 bytes, not 5:
struct S { char c; int i; };
offset: 0 1 2 3 4 5 6 7
+----+---------+ +--------------+
| c | padding | | i |
+----+---------+ +--------------+
1B 3B 4B total = 8
The 3 padding bytes align 'i' to a 4-byte boundary, because most
hardware loads a 4-byte int fastest from an address divisible by 4.
Key Takeaways
sizeofyieldssize_t(print with%zu) and measures in units wheresizeof(char) == 1.- The operand is not evaluated — side effects inside
sizeofnever happen (except with VLAs). - Prefer
malloc(n * sizeof *p)overmalloc(n * sizeof(int)): it stays correct when the type changes. sizeof arr / sizeof arr[0]gives the element count only wherearris a true array, never after decay to a pointer.sizeof(struct)includes padding, so it is usually larger than the sum of its members.
🧪 Practice
- Reproduce the
broken_countbug and print both values. Then rewrite the function to take an explicit length.- Define three structs with the same members in different orders and print
sizeoffor each. Explain the differences.- Write a macro
#define ARRAY_LEN(a) (sizeof (a) / sizeof (a)[0])and describe a situation where it silently gives the wrong answer.- Interview-style: "What does
sizeof(sizeof(int))evaluate to, and why?" Hint: what is the type of the inner expression's result?
<a id="22-variables-and-literals"></a>
2.2 Variables and Literals
Having types is one thing; naming values and writing them down is another. This section covers how names come into existence, how constants are spelled, and the three qualifiers that change what the compiler may assume about an object.
Declaration vs Definition
Theory
C separates introducing a name from creating the thing. This distinction is invisible in single-file programs and essential in multi-file ones.
- A declaration tells the compiler that a name exists and what its type is. It is a promise.
- A definition additionally allocates storage (for objects) or provides a body (for functions). It is the thing itself.
Every definition is also a declaration; not every declaration is a definition.
extern int counter; /* declaration: "an int named counter exists somewhere" */
int counter; /* definition: allocates storage */
int add(int, int); /* declaration: prototype only */
int add(int a, int b) /* definition: has a body */
{ return a + b; }
The rule that governs multi-file programs: a name may be declared many times but defined exactly once across the whole program. This is why headers contain declarations and .c files contain definitions. Put a definition in a header, include it from two files, and the linker reports multiple definition of 'counter'.
extern is the keyword that makes a declaration explicitly not a definition for objects. For functions it is redundant (a prototype without a body is already just a declaration), which is why most style guides omit it there.
A subtlety at file scope: int counter; with no initializer is a tentative definition. If no other definition appears in the translation unit, it becomes a definition initialized to zero. This is why forgetting extern in a header sometimes appears to work (the linker may merge tentative definitions as a common-symbol extension) and sometimes fails loudly — GCC 10+ defaults to -fno-common, making it an error.
Examples
/* ---------- config.h : declarations only ---------- */
#ifndef CONFIG_H
#define CONFIG_H
extern int verbosity; /* DECLARATION: no storage allocated here */
extern const char *app_name; /* DECLARATION */
int config_load(const char *path); /* declaration (prototype) */
void config_reset(void); /* declaration */
#endif
/* ---------- config.c : exactly one definition of each ---------- */
#include "config.h"
int verbosity = 0; /* DEFINITION: storage allocated here */
const char *app_name = "myapp"; /* DEFINITION */
int config_load(const char *path) /* DEFINITION: has a body */
{
(void)path;
verbosity = 1;
return 0;
}
void config_reset(void) { verbosity = 0; }
/* ---------- main.c : uses the declarations ---------- */
#include <stdio.h>
#include "config.h"
int main(void)
{
config_load("app.conf");
printf("%s verbosity=%d\n", app_name, verbosity);
return 0;
}
What goes wrong if you drop extern:
/* BAD header: this is a DEFINITION, not a declaration. */
int verbosity; /* included by main.c AND config.c */
$ gcc main.o config.o -o app
/usr/bin/ld: config.o:(.bss+0x0): multiple definition of `verbosity';
main.o:(.bss+0x0): first defined here
Local variables follow the same logic but with an important addition:
void f(void)
{
int x; /* definition; automatic storage; UNINITIALIZED (garbage) */
int y = 0; /* definition with initializer */
static int z; /* definition; static storage; ZERO-initialized */
/* extern int g; is a declaration referring to a file-scope object */
}
Objects with static storage duration (globals and static locals) are zero-initialized automatically. Automatic (stack) variables are not — reading one before assignment is undefined behavior.
Key Takeaways
- A declaration introduces a name and type; a definition also allocates storage or supplies a body.
- A name may be declared repeatedly but defined exactly once program-wide.
- Headers hold
externdeclarations; the matching definition lives in exactly one.cfile. - Omitting
externin a header creates a definition per including file, causingmultiple definitionat link time. - Static-storage objects are zero-initialized; automatic variables contain garbage until assigned.
🧪 Practice
- Build the three-file example above, then remove
externfrom the header and record the exact linker error.- Declare
extern int missing;in a file, use it, and compile without ever defining it. Which tool reports the error, and what does it say?- Print an uninitialized automatic
intand an uninitializedstatic intin the same function. Explain the difference.- Interview-style: "Can you have two definitions of the same function in one program?" Hint: consider
staticand what internal linkage does to the name.
Identifiers and Keywords
Theory
An identifier is a name you give to a variable, function, type, macro, or label. The syntactic rules are simple; the reservation rules are where people get into trouble.
Syntax:
- Made of letters, digits, and underscore (
_). - Cannot start with a digit.
- Case sensitive (
countandCountare different). - Cannot be a keyword.
- C99 also permits universal character names (Unicode) in identifiers, though few codebases use them.
Guaranteed-distinct lengths: C99 requires at least 63 significant characters for internal identifiers and 31 for external (linker-visible) ones. Modern toolchains impose no practical limit.
Reserved identifiers are the part that bites. The standard reserves entire patterns for the implementation, and using them is undefined behavior even though it usually compiles:
| Pattern | Reserved for | Example of a name to avoid |
|---|---|---|
_ + uppercase letter | Always, any scope | _Buffer, _MAX |
__ (double underscore) anywhere | Always, any scope | my__var, __helper |
_ + lowercase, at file scope | File-scope identifiers | _count as a global |
str, mem, wcs + lowercase | Future <string.h> additions | strjoin, memclear |
is, to + lowercase | Future <ctype.h> additions | islower_ascii |
E + digit or uppercase | <errno.h> | E2BIG_CUSTOM |
LC_ + uppercase | <locale.h> | LC_MYTHING |
SIG / SIG_ + uppercase | <signal.h> | SIGMYEVENT |
int/uint … _t | <stdint.h> | uint24_t |
The practical rule: prefix your project's public names with a short project tag (http_parse, img_load) rather than a leading underscore. This avoids reserved patterns and gives you a namespace at the same time.
C's keyword list has grown across standards:
| Standard | Added keywords |
|---|---|
| C89 | auto break case char const continue default do double else enum extern float for goto if int long register return short signed sizeof static struct switch typedef union unsigned void volatile while |
| C99 | inline restrict _Bool _Complex _Imaginary |
| C11 | _Alignas _Alignof _Atomic _Generic _Noreturn _Static_assert _Thread_local |
| C23 | alignas alignof bool constexpr false nullptr static_assert thread_local true typeof typeof_unqual (and more) |
Note the C11 keywords all begin with _ + uppercase precisely because that pattern was reserved — the committee left itself room to add keywords without breaking existing code.
Examples
#include <stdio.h>
/* Valid identifiers */
int count;
int _internal_ok_at_block_scope; /* risky at file scope -- reserved there */
int MAX_SIZE;
int camelCase;
int snake_case;
int x2;
int _; /* legal, though unhelpful */
/* Invalid -- each of these is a compile error */
/* int 2fast; starts with a digit */
/* int my-var; hyphen is not an identifier char */
/* int int; keyword */
/* int my var; space */
/* Reserved patterns: legal to the compiler, UNDEFINED BEHAVIOR per the standard */
/* int __helper; double underscore anywhere */
/* int _Buffer; underscore + uppercase */
/* void strjoin(); str + lowercase */
/* Good practice: namespace with a project prefix. */
typedef struct http_request http_request;
int http_parse(const char *raw, http_request *out);
void http_free(http_request *req);
int main(void)
{
/* Case sensitivity is real and a common source of typos. */
int value = 1, Value = 2, VALUE = 3;
printf("%d %d %d\n", value, Value, VALUE); /* 1 2 3 */
return 0;
}
Key Takeaways
- Identifiers use letters, digits, and
_; they cannot begin with a digit and are case sensitive. - Names beginning with
_+ uppercase, or containing__anywhere, are reserved to the implementation in every scope. - A leading
_at file scope is also reserved; use a project prefix (http_,img_) instead. str,mem,is,to,E,SIG,LC_prefixes are reserved for future standard-library growth.- Keywords differ by standard; C11's
_Alignas-style spellings exist to avoid breaking pre-existing code.
🧪 Practice
- Write a list of ten identifiers, half valid and half invalid or reserved, and check each against a compiler with
-Wall -pedantic. Note which reserved names compile without complaint.- Rename a small module's public functions to use a consistent project prefix and observe how it changes readability at call sites.
- Interview-style: "Why do C11 keywords look like
_Static_assertinstead ofstatic_assert?" Hint: what would happen to a 2005 codebase that had already defined its ownstatic_assertmacro?
Integer, Floating, Character, and String Literals
Theory
A literal (the standard says "constant") is a value written directly in source code. What surprises most newcomers is that every literal has a type, determined by rules you can control with prefixes and suffixes — and getting that type wrong causes real bugs.
Integer literals. The base comes from the prefix:
| Form | Base | Example | Value |
|---|---|---|---|
| No prefix | Decimal | 42 | 42 |
0 prefix | Octal | 042 | 34 |
0x / 0X | Hexadecimal | 0x2A | 42 |
0b / 0B (C23) | Binary | 0b101010 | 42 |
The octal rule is a genuine trap: 0755 is 493, and a zero-padded "decimal" like 010 is 8. This bites when formatting data or writing lookup tables.
Suffixes control the type:
| Suffix | Meaning | Example |
|---|---|---|
| none | int, then long, then long long (first that fits) | 42 |
u / U | unsigned | 42u |
l / L | long | 42L |
ll / LL | long long | 42LL |
| combined | ul, ull, … | 42ULL |
Without a suffix, a decimal literal gets the first signed type that can hold it. This matters: 1 << 31 overflows a 32-bit int (undefined behavior), while 1u << 31 is fine.
Floating literals. An unsuffixed literal is a double, not a float:
| Suffix | Type | Example |
|---|---|---|
| none | double | 3.14 |
f / F | float | 3.14f |
l / L | long double | 3.14L |
Scientific notation (1.5e-10) and C99 hex float notation (0x1.8p3 = 12.0) are also available. Writing float x = 3.14; computes a double and then narrows it — usually harmless but wasteful, and compilers warn under -Wdouble-promotion.
Character literals. Here is C's most surprising rule: 'a' has type int, not char, so sizeof('a') is 4 on a typical machine (in C++ it is 1). The value is the character's encoding in the execution character set — usually ASCII, so 'A' is 65.
Prefixes: L'x' (wchar_t), u'x' (char16_t), U'x' (char32_t), and C23's u8'x' (char8_t).
String literals. A string literal "hello" has type char[6] — five characters plus the automatic terminating '\0'. Key properties:
- It has static storage duration: it exists for the whole program.
- Attempting to modify it is undefined behavior, even though its type is not
const char[]in C (a historical wart). In practice it lives in read-only memory and writing segfaults. - Adjacent literals are concatenated at compile time:
"foo" "bar"is"foobar". This is how thePRId32macros work.
The critical distinction:
char *p = "hello"; /* pointer to a read-only literal -- do NOT modify */
char a[] = "hello"; /* a modifiable 6-byte array, COPIED from the literal */
Examples
#include <stdio.h>
int main(void)
{
/* --- Integer literals: base matters --- */
printf("42 = %d\n", 42); /* 42 decimal */
printf("042 = %d\n", 042); /* 34 OCTAL -- the leading zero */
printf("0x2A = %d\n", 0x2A); /* 42 hex */
printf("0b101010 = %d\n", 0b101010); /* 42 binary (C23; GCC extension earlier) */
/* Suffix controls the type, which controls the arithmetic. */
printf("\n1 << 31 is UB on 32-bit int; 1u << 31 = %u\n", 1u << 31);
printf("sizeof 42 = %zu\n", sizeof 42); /* 4 (int) */
printf("sizeof 42L = %zu\n", sizeof 42L); /* 8 (long, LP64) */
printf("sizeof 42LL = %zu\n", sizeof 42LL); /* 8 (long long) */
/* --- Floating literals default to double --- */
printf("\nsizeof 3.14 = %zu\n", sizeof 3.14); /* 8 (double) */
printf("sizeof 3.14f = %zu\n", sizeof 3.14f); /* 4 (float) */
printf("1.5e-10 = %g, 0x1.8p3 = %g\n", 1.5e-10, 0x1.8p3); /* ..., 12 */
/* --- Character literals are int in C --- */
printf("\n'A' = %d, sizeof 'A' = %zu\n", 'A', sizeof 'A'); /* 65, 4 */
printf("'0' = %d\n", '0'); /* 48 */
printf("digit value of '7' = %d\n", '7' - '0'); /* 7 -- common idiom */
/* --- String literals --- */
printf("\nsizeof \"hello\" = %zu\n", sizeof "hello"); /* 6: 5 chars + '\0' */
char *p = "hello"; /* points into read-only storage */
char a[] = "hello"; /* a private, modifiable copy */
a[0] = 'H'; /* fine */
/* p[0] = 'H'; */ /* UNDEFINED BEHAVIOR -- usually a segfault */
printf("a = %s, p = %s\n", a, p);
/* Adjacent literals concatenate at compile time. */
const char *msg = "This is one "
"long string "
"split across lines.";
puts(msg);
return 0;
}
Memory picture of the two string forms:
char *p = "hello"; char a[] = "hello";
p (8 bytes, on stack) a (6 bytes, on stack)
+----------+ +---+---+---+---+---+----+
| 0x400604 |---+ | h | e | l | l | o | \0 |
+----------+ | +---+---+---+---+---+----+
| ^ modifiable: it is YOUR copy
v
.rodata (read-only section)
+---+---+---+---+---+----+
| h | e | l | l | o | \0 | <- writing here is undefined behavior
+---+---+---+---+---+----+
Key Takeaways
- A leading
0means octal:042is 34, not 42. Never zero-pad decimal literals. - Unsuffixed integer literals are
int(thenlong,long long); useu/L/LLto control the type in shifts and comparisons. - Unsuffixed floating literals are
double; write3.14fwhen you want afloat. 'a'has typeintin C, sosizeof 'a'is 4;'7' - '0'is the standard digit-conversion idiom."hello"is achar[6]with static storage; modifying a string literal is undefined behavior — usechar a[] = "..."for a mutable copy.
🧪 Practice
010,10, and0x10and explain the three different values. Then find a real-world case (file permissions, escape codes) where octal is the natural notation.- Write
char *p = "test"; p[0] = 'T';and run it. Record what happens, then fix it two different ways.- Demonstrate that
sizeof('a') != sizeof(char)in C, and explain why this differs from C++.- Interview-style: "What is the difference between
char *s = "abc"andchar s[] = "abc"in terms of storage and mutability?" Hint: draw where the bytes live for each.
Escape Sequences
Theory
Some characters cannot be typed literally inside a string: a newline would end the line, a double quote would end the string, and control characters have no printable form. Escape sequences are the backslash-introduced spellings that stand in for them.
The complete standard set:
| Escape | Name | ASCII value |
|---|---|---|
\n | Newline (line feed) | 10 |
\t | Horizontal tab | 9 |
\r | Carriage return | 13 |
\\ | Backslash | 92 |
\' | Single quote | 39 |
\" | Double quote | 34 |
\? | Question mark (avoids trigraphs) | 63 |
\0 | Null character | 0 |
\a | Alert (bell) | 7 |
\b | Backspace | 8 |
\f | Form feed | 12 |
\v | Vertical tab | 11 |
\ooo | Octal, 1–3 digits | varies |
\xhh… | Hexadecimal, 1+ digits | varies |
\uXXXX, \UXXXXXXXX | Universal character name (C99) | varies |
Two traps deserve attention.
The hex escape is greedy. \x consumes as many hex digits as follow it, with no two-digit limit. So "\x41BC" is not 'A' followed by "BC" — it is a single escape \x41BC, which overflows a char and is a compile error or implementation-defined. The fix is string concatenation: "\x41" "BC".
\0 is just an octal escape. \0 is octal zero, and \012 is octal 12 = decimal 10 = newline. So "\0" and "\00" are the null character, but "\08" is \0 followed by the character 8 (since 8 is not an octal digit).
Note also that \n is a single character (value 10). On Windows text-mode streams the C runtime translates it to CR+LF on output, but in memory it is always one byte. That translation is why you open binary files with "rb"/"wb".
Examples
#include <stdio.h>
int main(void)
{
/* Common escapes in action */
printf("Line one\nLine two\n"); /* newline */
printf("Col1\tCol2\tCol3\n"); /* tab-separated */
printf("She said \"hello\" to me.\n"); /* embedded quotes */
printf("Path: C:\\Users\\dev\\file.txt\n"); /* literal backslashes */
printf("A bell: \a\n"); /* audible alert */
/* Numeric escapes: three ways to write 'A' (65 = 0101 octal = 0x41) */
printf("%c %c %c\n", 'A', '\101', '\x41'); /* A A A */
/* The greedy hex trap */
/* printf("\x41BC\n"); */ /* ERROR: hex escape out of range */
printf("%s\n", "\x41" "BC"); /* correct: "ABC" */
/* \0 is octal zero; it terminates C strings */
const char embedded[] = "abc\0def"; /* 8 bytes, but strlen is 3 */
printf("sizeof = %zu, prints as: %s\n", sizeof embedded, embedded);
/* A progress-bar idiom using carriage return (no newline) */
for (int i = 0; i <= 100; i += 25) {
printf("\rProgress: %3d%%", i); /* \r returns to column 0 */
fflush(stdout); /* force output without a newline */
}
printf("\n");
/* ANSI terminal colors use the ESC character, \033 or \x1b */
printf("\033[31mred text\033[0m and normal\n");
return 0;
}
Byte-level view of an escape-heavy string:
"a\tb\n"
'a' '\t' 'b' '\n' '\0'
0x61 0x09 0x62 0x0A 0x00 <- 5 bytes total, sizeof == 5
^^^^ ^^^^^^^^^^ ^^^^
one one char one char
char (not two) (not two)
Key Takeaways
- Escape sequences let you embed newlines, quotes, backslashes, and control characters in literals.
\nis one character (value 10), not the two characters\andn.\xhhis greedy — it consumes every following hex digit; break the string to stop it ("\x41" "BC").\0is octal zero and terminates C strings; a string with an embedded\0hassizeoflarger thanstrlen.- Windows paths in literals need doubled backslashes (
"C:\\dir"), or use forward slashes, which the Windows API accepts.
🧪 Practice
- Print a small table using
\talignment, then the same table usingprintfwidth specifiers. Compare robustness when the values vary in length.- Create
char s[] = "ab\0cd";and print bothsizeof sandstrlen(s). Explain the difference.- Write the string
"\x41BC"and record the compiler error, then fix it with concatenation.- Interview-style: "How many bytes does
"a\\b"occupy?" Hint: count the characters after the compiler processes the escape, then add the terminator.
const Qualifier
Theory
const means "this object will not be modified through this name." That framing matters: it is a promise about access, not a guarantee that the underlying memory is immutable.
What it buys you:
- Compiler-enforced intent. Attempting to assign through a
constname is a compile error, catching bugs early. - Self-documenting interfaces.
void print(const char *s)tells every caller the string is safe. - Optimization opportunities. The compiler may place
constobjects with static storage in read-only memory.
A critical difference from C++: in C, a const object is not a compile-time constant expression. You cannot use it as an array size in C89, in a case label, or in a bit-field width. That is what #define and enum are for.
const int SIZE = 10;
int arr1[SIZE]; /* C89: error. C99: a VLA, not a fixed array. */
#define SIZE2 10
int arr2[SIZE2]; /* fine: 10 is a literal after preprocessing */
enum { SIZE3 = 10 };
int arr3[SIZE3]; /* fine: enum constants ARE constant expressions */
With pointers, const can qualify two different things, and reading the declaration right-to-left resolves it:
const int *p; /* p is a pointer to a const int -- can't do *p = x */
int const *p; /* identical to the above */
int *const p; /* p is a const pointer to int -- can't do p = &y */
const int *const p; /* both are const */
The mnemonic: const applies to what is on its left, unless there is nothing on its left, in which case it applies to what is on its right.
Finally, casting away const and then writing is undefined behavior if the object was actually defined as const. The cast compiles; the write may segfault.
Examples
#include <stdio.h>
#include <string.h>
/* const in an interface: "I will read your buffer, never write it." */
size_t count_char(const char *s, char target)
{
size_t n = 0;
while (*s) { /* reading through a const pointer: fine */
if (*s == target) n++;
s++; /* moving the pointer itself: also fine --
s is a non-const pointer to const char */
}
/* *s = 'x'; ERROR: assignment of read-only location */
return n;
}
int main(void)
{
const int limit = 100;
/* limit = 200; */ /* ERROR: assignment of read-only variable */
printf("limit = %d\n", limit);
int x = 1, y = 2;
/* 1. Pointer to const int: the VALUE is protected. */
const int *p1 = &x;
/* *p1 = 5; */ /* ERROR */
p1 = &y; /* OK: repointing is allowed */
/* 2. Const pointer to int: the POINTER is protected. */
int *const p2 = &x;
*p2 = 5; /* OK: the value can change */
/* p2 = &y; */ /* ERROR: p2 itself is const */
/* 3. Both const. */
const int *const p3 = &x;
/* *p3 = 5; ERROR */
/* p3 = &y; ERROR */
printf("x=%d *p3=%d\n", x, *p3);
/* const arrays of strings: a very common idiom */
static const char *const days[] = {
"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"
};
printf("%s\n", days[2]);
printf("count = %zu\n", count_char("hello world", 'l'));
return 0;
}
Reading complex declarations right-to-left:
const char * const * p;
^ p
^ is a pointer
^^^^^ to a const pointer
^^^^ to a const char
Read: "p is a pointer to a const pointer to a const char."
Key Takeaways
constpromises that an object will not be modified through that particular name.- In C (unlike C++) a
constobject is not a constant expression — use#defineorenumfor array sizes andcaselabels. const int *pprotects the pointed-to value;int *const pprotects the pointer; read right-to-left.- Marking pointer parameters
constdocuments that a function only reads its input, and enables better diagnostics. - Casting away
constand writing to a genuinelyconstobject is undefined behavior, typically a segfault.
🧪 Practice
- Write all four pointer/
constcombinations, then attempt both*p = ...andp = ...on each. Record which compile.- Add
constto every read-only pointer parameter in a small program of yours. Note any places the compiler then rejects — those were real modifications you may not have intended.- Try
const int n = 5; int a[n];under-std=c89 -pedanticand-std=c99. Explain both outcomes.- Interview-style: "What does
char * const * const pmean?" Hint: apply the right-to-left rule one qualifier at a time.
volatile Qualifier
Theory
Compilers optimize on an assumption: the only thing that changes memory is your program. Given that, a loop that reads the same variable repeatedly without writing it can safely read once and cache the value in a register.
That assumption is false in three situations, and volatile is how you tell the compiler so:
- Memory-mapped hardware registers. A status register at a fixed address changes because a peripheral changed it.
- Variables modified by a signal handler. The handler can run between any two instructions.
- Variables shared with another thread — though for threads,
volatileis not sufficient; you need atomics or a mutex, becausevolatileprovides no ordering or atomicity guarantees.
volatile means: every read must actually read memory, every write must actually write memory, and the compiler may not reorder these accesses relative to each other. It disables caching in registers and elimination of "redundant" accesses.
Without it, this hardware polling loop can compile into an infinite loop:
uint32_t *status = (uint32_t *)0x40001000;
while (*status == 0) { } /* compiler reads ONCE, loops on the register */
With volatile, each iteration re-reads the address:
volatile uint32_t *status = (volatile uint32_t *)0x40001000;
while (*status == 0) { } /* correct: reads memory every iteration */
The essential caveat: volatile is not a threading primitive. It guarantees the access happens; it does not make it atomic and does not prevent the CPU from reordering. For inter-thread communication use _Atomic (C11), stdatomic.h, or mutexes. For signal handlers use volatile sig_atomic_t, which the standard specifically blesses.
Examples
#include <stdio.h>
#include <signal.h>
#include <stdint.h>
/* The standard's own type for signal-handler communication. */
static volatile sig_atomic_t stop_requested = 0;
static void handle_sigint(int sig)
{
(void)sig;
stop_requested = 1; /* set asynchronously, outside normal flow */
}
int main(void)
{
signal(SIGINT, handle_sigint);
/* Without 'volatile', the compiler may hoist the read out of the loop
and never observe the handler's write -- an infinite loop. */
unsigned long spins = 0;
while (!stop_requested) {
if (++spins > 100000000UL) break; /* bounded, so the demo terminates */
}
printf("\nstopped after %lu spins (stop_requested = %d)\n",
spins, (int)stop_requested);
return 0;
}
Hardware register access, the canonical embedded pattern:
#include <stdint.h>
/* Each read/write must reach the peripheral, in program order. */
#define UART_STATUS (*(volatile uint32_t *)0x40011000)
#define UART_DATA (*(volatile uint32_t *)0x40011004)
#define UART_TX_READY (1u << 7)
void uart_putc(char c)
{
/* Poll until the transmitter is ready. Requires a fresh read each time. */
while ((UART_STATUS & UART_TX_READY) == 0) {
/* spin */
}
UART_DATA = (uint32_t)(unsigned char)c; /* the write must not be elided */
}
What the qualifier changes, conceptually:
int flag; volatile int flag;
while (!flag) { } while (!flag) { }
| |
v compiler may generate: v compiler must generate:
load flag -> r1 loop: load flag -> r1
loop: test r1 test r1
jump loop jump loop
(infinite: r1 never reloaded) (correct: memory read each pass)
Note the pointer syntax mirrors const:
volatile int *p; /* pointer to volatile int (the usual MMIO case) */
int *volatile p; /* volatile pointer to ordinary int (rare) */
volatile int *volatile p; /* both */
Key Takeaways
volatiletells the compiler that an object may change outside the program's control, forcing a real memory access on every read and write.- Its three legitimate uses are memory-mapped I/O,
sig_atomic_tflags set by signal handlers, andsetjmp/longjmplocals. - It is not a synchronization primitive: it provides no atomicity and no CPU-level ordering. Use
_Atomicor mutexes for threads. - Without
volatile, a polling loop on a hardware register can be optimized into an infinite loop. volatile int *p(pointer to volatile) is the MMIO form; the qualifier follows the same right-to-left reading rules asconst.
🧪 Practice
- Write a loop that spins on a non-
volatileglobal, compile at-O0and-O2, and inspect the assembly withgcc -S. Then addvolatileand compare.- Implement a
SIGINThandler that sets avolatile sig_atomic_tflag, and a main loop that exits cleanly when it is set.- Write both
volatile int *pandint *volatile p, then attempt to modify*pandpfor each. Record which compile.- Interview-style: "Is
volatileenough to share a counter between two threads?" Hint: distinguish "the access happens" from "the access is atomic and ordered."
restrict Qualifier
Theory
restrict (C99) is a promise you make to the compiler about pointers. Applied to a pointer parameter, it asserts: for the lifetime of this pointer, the object it points to will be accessed only through this pointer (or pointers derived from it). In other words, no aliasing.
Why does that matter? Consider:
void add_arrays(int *a, int *b, int *sum, int n)
{
for (int i = 0; i < n; i++)
sum[i] = a[i] + b[i];
}
The compiler cannot know whether sum overlaps a or b. If sum == a + 1, then writing sum[0] changes a[1], so the compiler must reload a[i] and b[i] on every iteration and cannot vectorize the loop. Adding restrict removes that doubt:
void add_arrays(int *restrict a, int *restrict b, int *restrict sum, int n)
Now the compiler can load, compute, and store in wide SIMD chunks.
The catch is that restrict is unchecked. If you lie — pass overlapping pointers to a restrict function — the behavior is undefined, and the bug will be silent and data-dependent. The compiler will not warn you.
The most famous example in the standard library is the memcpy/memmove pair:
void *memcpy(void *restrict dest, const void *restrict src, size_t n);
void *memmove(void *dest, const void *src, size_t n); /* no restrict */
memcpy may be fast precisely because it assumes no overlap. memmove handles overlap correctly and is slightly slower. Using memcpy on overlapping buffers is undefined behavior, and it does misbehave in practice on optimized implementations.
Note that restrict applies only to pointers, and it is meaningful mainly on function parameters and on local pointers within a block.
Examples
#include <stdio.h>
#include <string.h>
/* Promise: dst does not overlap src. The compiler may vectorize freely. */
void scale_copy(double *restrict dst, const double *restrict src,
size_t n, double factor)
{
for (size_t i = 0; i < n; i++)
dst[i] = src[i] * factor; /* no reload of src needed: no aliasing */
}
/* No promise: the compiler must assume dst and src may overlap. */
void scale_copy_safe(double *dst, const double *src, size_t n, double factor)
{
for (size_t i = 0; i < n; i++)
dst[i] = src[i] * factor; /* conservative codegen */
}
int main(void)
{
double a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
double b[8];
scale_copy(b, a, 8, 2.0); /* correct: b and a are distinct */
for (int i = 0; i < 8; i++) printf("%g ", b[i]);
printf("\n");
/* scale_copy(a + 1, a, 7, 2.0); UNDEFINED BEHAVIOR: they overlap. */
scale_copy_safe(a + 1, a, 7, 2.0); /* defined, though still order-sensitive */
/* memcpy vs memmove on overlapping memory */
char buf[] = "abcdefgh";
/* memcpy(buf + 2, buf, 5); */ /* UB: regions overlap */
memmove(buf + 2, buf, 5); /* correct: handles overlap */
printf("%s\n", buf); /* ababcdeh */
return 0;
}
Why the compiler is stuck without restrict:
Without restrict, the compiler must consider this possibility:
src: [ s0 ][ s1 ][ s2 ][ s3 ]
dst: [ d0 ][ d1 ][ d2 ][ d3 ]
^
dst[0] and src[1] are the SAME memory
So after writing dst[0], the value of src[1] may have changed
-> must reload src[i] every iteration
-> cannot process four elements at once with SIMD
With restrict, you guarantee the regions are disjoint, so:
-> load 4 src values, multiply, store 4 dst values, per iteration
Key Takeaways
restrictpromises that the pointed-to object is accessed only through that pointer, enabling vectorization and reduced reloads.- The promise is unchecked: violating it is undefined behavior with no diagnostic.
memcpydeclaresrestricton both arguments; usememmovewhenever the regions may overlap.- It applies only to pointers and matters most on function parameters in numeric or buffer-processing code.
- Add it only where you can genuinely guarantee non-overlap, and document the requirement at the call sites.
🧪 Practice
- Write a vector-addition function with and without
restrict, compile both at-O3, and diff the assembly (look forxmm/ymmSIMD registers).- Call
memcpywith overlapping regions and observe the result; then usememmoveon the same data and compare.- Add
restrictto a function and then deliberately pass overlapping pointers. Does the compiler warn? Does the output change between-O0and-O2?- Interview-style: "Why does
strcpynot declare its parametersrestrictin every implementation, whilememcpydoes?" Hint: check the actual C standard signatures for both — and think about what the caller can reasonably guarantee.
<a id="23-operators"></a>
2.3 Operators
Operators are where types meet computation. C has a large operator set with precise rules about precedence, evaluation order, and the types of results — and several operators behave differently than newcomers expect.
Arithmetic Operators
Theory
C provides five arithmetic operators: +, -, *, /, %. Four behave as expected. The two that require care are integer division and the modulus operator.
Integer division truncates. If both operands are integers, the result is an integer with the fractional part discarded — not rounded:
7 / 2 == 3 /* not 3.5, not 4 */
-7 / 2 == -3 /* truncation is TOWARD ZERO (C99+) */
Before C99, the direction of truncation for negative operands was implementation-defined. C99 standardized it: division truncates toward zero, and % takes the sign of the dividend (the left operand). The invariant the standard guarantees is:
(a / b) * b + (a % b) == a
Working through -7 / 2 and -7 % 2: -7 / 2 is -3, so -3 * 2 + r == -7, giving r == -1. Hence -7 % 2 == -1, not 1. This differs from Python, where -7 % 2 == 1 — a frequent source of confusion for people arriving from other languages.
Division or modulus by zero is undefined behavior, not an exception. On most hardware integer division by zero raises SIGFPE and crashes; floating-point division by zero produces infinity (under IEEE 754). Always check the divisor.
% requires integer operands. For floating-point remainder use fmod() from math.h.
Unary - on the most-negative integer (-INT_MIN) overflows and is undefined behavior, because INT_MAX is one less than |INT_MIN| in two's complement.
Examples
#include <stdio.h>
#include <math.h> /* fmod */
int main(void)
{
/* Integer division truncates toward zero. */
printf(" 7 / 2 = %d\n", 7 / 2); /* 3 */
printf("-7 / 2 = %d\n", -7 / 2); /* -3 (toward zero, not -4) */
printf(" 7 %% 2 = %d\n", 7 % 2); /* 1 */
printf("-7 %% 2 = %d\n", -7 % 2); /* -1 (sign follows the dividend) */
printf(" 7 %% -2 = %d\n", 7 % -2); /* 1 */
/* Verify the standard's invariant. */
int a = -7, b = 2;
printf("(a/b)*b + a%%b = %d (should be %d)\n", (a/b)*b + a%b, a);
/* To get a floating result, at least one operand must be floating. */
printf("\n7 / 2 = %d (integer division)\n", 7 / 2);
printf("7.0 / 2 = %g (one double operand promotes the other)\n", 7.0 / 2);
printf("(double)7 / 2 = %g\n", (double)7 / 2);
printf("7 / (double)2 = %g\n", 7 / (double)2);
/* A classic bug: the division happens BEFORE the conversion. */
int total = 7, count = 2;
double wrong = total / count; /* 3.0 -- integer division first */
double right = (double)total / count; /* 3.5 */
printf("\nwrong = %g, right = %g\n", wrong, right);
/* Floating-point remainder needs fmod. */
printf("fmod(7.5, 2.0) = %g\n", fmod(7.5, 2.0)); /* 1.5 */
/* Always guard the divisor. */
int divisor = 0;
if (divisor != 0) printf("%d\n", 10 / divisor);
else printf("division by zero avoided\n");
/* A safe way to round up an integer division. */
int items = 17, per_page = 5;
int pages = (items + per_page - 1) / per_page; /* ceiling division */
printf("pages = %d\n", pages); /* 4 */
return 0;
}
Truncation directions compared:
value C (trunc toward 0) floor() Python's //
------ ------------------ ------- -----------
7 / 2 3 3 3
-7 / 2 -3 -4 -4
7 % 2 1 1 1
-7 % 2 -1 1 1
Key Takeaways
- Integer division truncates toward zero;
7/2 == 3and-7/2 == -3. %takes the sign of the left operand (dividend):-7 % 2 == -1, unlike Python.- The invariant
(a/b)*b + (a%b) == aalways holds for well-defined operands. - Division or modulus by zero is undefined behavior for integers; for floating point it yields infinity or NaN.
- To get a floating result, cast one operand before the division:
(double)total / count, not(double)(total / count).
🧪 Practice
- Write a program printing
a / banda % bfor all sign combinations of7and2. Verify the standard's invariant for each.- Implement
int floor_div(int a, int b)andint floor_mod(int a, int b)that behave like Python's//and%for negative operands.- Write a function that computes a percentage as a
doublefrom twointcounts, correct when the numerator is smaller than the denominator.- Interview-style: "How do you compute the ceiling of an integer division without floating point?" Hint:
(a + b - 1) / b— and consider when that expression can overflow.
Relational and Equality Operators
Theory
The relational operators (<, >, <=, >=) and equality operators (==, !=) compare two values and yield an int: 1 for true, 0 for false. Not a bool — an int. This is why printf("%d", 3 > 2) prints 1.
Three behaviors deserve attention.
1. == versus =. The single most common C typo is writing assignment where comparison was meant. Because assignment is an expression whose value is the assigned value, if (x = 5) compiles, assigns 5 to x, and is always true. Compilers warn under -Wall, and the defensive "Yoda condition" (if (5 == x)) makes the mistake a compile error, though modern warnings make it largely unnecessary.
2. Comparing floating-point with == is almost always wrong. Because 0.1 + 0.2 != 0.3 in binary, exact comparison fails for computed values. Compare with a tolerance instead (see 2.4).
3. Mixed signed/unsigned comparison converts the signed operand to unsigned, as covered in 2.1. -1 < 1u is false.
Two pointer-specific rules:
- Comparing pointers with
==/!=is always well-defined. - Comparing with
</>is only defined for pointers into the same array object (or one past its end). Ordering pointers to unrelated objects is undefined behavior, even though it appears to work.
Finally, chained comparisons do not mean what they do in mathematics. a < b < c parses as (a < b) < c, comparing 0 or 1 against c.
Examples
#include <stdio.h>
#include <string.h>
#include <math.h>
int main(void)
{
/* Results are int 0 or 1, not bool. */
printf("(3 > 2) = %d\n", 3 > 2); /* 1 */
printf("(3 == 2) = %d\n", 3 == 2); /* 0 */
printf("sizeof (3 > 2) = %zu\n", sizeof (3 > 2)); /* 4 -- it's an int */
/* Assignment vs comparison */
int x = 0;
if (x = 5) /* WARNING: assignment, always true */
printf("x was assigned %d\n", x);
x = 0;
if (x == 5) printf("never\n");
else printf("x == 5 is false\n");
/* Chained comparison does not mean what it looks like. */
int a = 1, b = 5, c = 3;
printf("\n(a < b < c) = %d\n", a < b < c); /* (1<5)=1, then 1<3 = 1 */
printf("correct form = %d\n", a < b && b < c); /* 0 -- 5 < 3 is false */
/* Floating-point equality */
double f = 0.1 + 0.2;
printf("\n(0.1+0.2 == 0.3) = %d\n", f == 0.3); /* 0 */
printf("with tolerance = %d\n", fabs(f - 0.3) < 1e-9); /* 1 */
/* Strings compare by ADDRESS with ==, not content. */
char s1[] = "hello", s2[] = "hello";
printf("\n(s1 == s2) = %d (compares pointers)\n", s1 == s2);
printf("strcmp(s1,s2) == 0 = %d (compares content)\n", strcmp(s1, s2) == 0);
/* Signed/unsigned comparison */
int i = -1; unsigned u = 1;
printf("\n(-1 < 1u) = %d (signed converted to unsigned)\n", i < u);
/* Pointer comparison within one array is well-defined. */
int arr[5];
int *p = &arr[1], *q = &arr[3];
printf("(p < q) = %d\n", p < q); /* 1 -- same array, defined */
return 0;
}
Key Takeaways
- Relational and equality operators yield
int0or1in C, notbool. =is assignment and==is comparison;if (x = 5)is legal, always true, and warned about under-Wall.- Never compare floating-point values with
==; use a tolerance appropriate to the magnitudes involved. s1 == s2on strings compares addresses; usestrcmp(s1, s2) == 0to compare content.a < b < cparses as(a < b) < c; writea < b && b < c.
🧪 Practice
- Write
if (x = 5)and compile with and without-Wall. Then fix it and confirm the warning disappears.- Demonstrate that
a < b < cgives a surprising result fora=1, b=5, c=3, and explain the two-step evaluation.- Compare two identical string literals with
==and withstrcmp. Then try the same with twochar[]arrays and explain any difference in the==result.- Interview-style: "Is
p < qwell-defined for two arbitrary pointers?" Hint: consider what guarantee the standard makes about pointers into different objects.
Logical Operators and Short-Circuiting
Theory
The logical operators are && (and), || (or), and ! (not). They treat any nonzero scalar as true and zero as false, and they produce int 0 or 1.
Their defining feature is short-circuit evaluation:
a && bevaluatesbonly ifais true. Ifais false, the result is 0 andbis never touched.a || bevaluatesbonly ifais false.
This is not merely an optimization — it is guaranteed by the standard, and there is a sequence point between the two operands, meaning the left side is fully evaluated (side effects included) before the right side begins. That guarantee makes this the standard idiom for guarded access:
if (p != NULL && p->value > 0) /* p->value is only evaluated if p is non-NULL */
if (i < n && arr[i] == target) /* bounds check before indexing */
Reverse the operands and you get a crash. Order is part of the semantics.
The corresponding pattern for defaults and early exits:
if (config == NULL || config->disabled) /* stops before dereferencing NULL */
Two frequent errors:
Confusing && with &. The bitwise operators (&, |) evaluate both operands and operate bit by bit. 2 && 1 is 1 (both truthy); 2 & 1 is 0 (no common bits). Using & where && was meant silently changes both the value and the evaluation behavior.
Assuming C has boolean-returning || like Python. In C, a || b yields 1, not a or b. The idiom x = a || b; gives x the value 1, not the first truthy operand.
Examples
#include <stdio.h>
#include <stdlib.h>
int side_effect(const char *label, int value)
{
printf(" [evaluated %s]\n", label);
return value;
}
typedef struct { int value; } Node;
int main(void)
{
/* Short-circuit: the right operand is never evaluated. */
puts("0 && side_effect():");
int r1 = 0 && side_effect("right", 1); /* right side skipped */
printf(" result = %d\n\n", r1);
puts("1 || side_effect():");
int r2 = 1 || side_effect("right", 1); /* right side skipped */
printf(" result = %d\n\n", r2);
puts("1 && side_effect():");
int r3 = 1 && side_effect("right", 1); /* right side IS evaluated */
printf(" result = %d\n\n", r3);
/* The guard idiom: order is load-bearing. */
Node *p = NULL;
if (p != NULL && p->value > 0) /* safe: never dereferences NULL */
puts("positive");
else
puts("null or non-positive");
/* if (p->value > 0 && p != NULL) <- CRASHES: dereference happens first */
/* Bounds check before indexing */
int arr[5] = {1, 2, 3, 4, 5};
int i = 10;
if (i < 5 && arr[i] == 3) puts("found"); /* arr[10] never accessed */
else puts("out of range");
/* && vs & are genuinely different operators. */
printf("\n2 && 1 = %d (logical: both truthy)\n", 2 && 1); /* 1 */
printf("2 & 1 = %d (bitwise: 010 & 001 = 000)\n", 2 & 1); /* 0 */
/* Logical operators yield 1, not the operand value. */
int a = 5, b = 7;
printf("\n(a || b) = %d (not 5)\n", a || b);
printf("!0 = %d, !5 = %d, !!5 = %d\n", !0, !5, !!5); /* 1, 0, 1 */
return 0;
}
Evaluation flow:
expr: f() && g()
+--------+ false (0)
| f() |----------------> result = 0 (g() NOT called)
+--------+
| true (nonzero)
v
+--------+
| g() |----------------> result = (g() != 0)
+--------+
Sequence point between the operands: every side effect of f()
is complete before g() begins.
Key Takeaways
&&and||short-circuit: the right operand is evaluated only when the result is not already determined.- There is a sequence point between the operands, so left-side side effects complete first — this makes NULL and bounds guards safe.
- Both operators yield
int1or0, never the operand's own value. &&/||are not&/|: the bitwise versions evaluate both sides and combine bits.!!xis the idiomatic way to normalize any scalar to0or1.
🧪 Practice
- Write a function with a
printfside effect and use it on both sides of&&and||with each combination of truthy/falsy left operands. Record which calls actually happen.- Write a linked-list search that uses
p != NULL && p->key == targetin the loop condition, then reverse the operands and observe the crash.- Replace
&&with&in a NULL-guard expression and explain precisely why it becomes unsafe.- Interview-style: "How would you write a safe division that returns 0 when the divisor is zero, in a single expression?" Hint:
b != 0 && ...does not return the quotient — think about the ternary operator instead.
Bitwise Operators and Shifts
Theory
Bitwise operators work on the individual bits of integer values. They exist because C is a systems language: flags, masks, hardware registers, and compact encodings all require bit-level control.
| Operator | Name | Effect |
|---|---|---|
& | AND | 1 where both bits are 1 |
| | OR | 1 where either bit is 1 |
^ | XOR | 1 where the bits differ |
~ | NOT | Flips every bit (unary) |
<< | Left shift | Moves bits left, filling with 0 |
>> | Right shift | Moves bits right |
a = 0b1100 (12) a & b = 0b1000 (8)
b = 0b1010 (10) a | b = 0b1110 (14)
a ^ b = 0b0110 (6)
~a = ...11110011 (depends on width)
a << 1 = 0b11000 (24)
a >> 1 = 0b0110 (6)
Left shift by n multiplies by 2^n; right shift divides by 2^n (for non-negative values).
The rules that cause undefined behavior are worth memorizing, because they are easy to trip:
| Situation | Status |
|---|---|
Shift count >= width of the promoted type | Undefined behavior |
| Shift count negative | Undefined behavior |
| Left-shifting a negative value | Undefined behavior (C99+) |
| Left shift where the result overflows a signed type | Undefined behavior |
| Right-shifting a negative value | Implementation-defined (arithmetic shift in practice) |
| Any shift on unsigned types within range | Well-defined |
1 << 31 on a 32-bit int overflows into the sign bit — undefined. Write 1u << 31. This one line prevents a whole class of bugs.
Also note that integer promotion applies before the operation: operands narrower than int are promoted to int first. So ~(unsigned char)0 is not 255 but -1 (as an int), and shifting an unsigned char by 20 is legal because it was promoted to a 32-bit int.
Examples
#include <stdio.h>
#include <stdint.h>
/* Print the low 8 bits of a value, most significant first. */
static void print_bits8(const char *label, unsigned v)
{
printf("%-10s ", label);
for (int i = 7; i >= 0; i--) putchar((v >> i) & 1u ? '1' : '0');
printf(" (%u)\n", v & 0xFFu);
}
/* Flag definitions: each is a distinct single bit. */
#define FLAG_READ (1u << 0) /* 0000 0001 */
#define FLAG_WRITE (1u << 1) /* 0000 0010 */
#define FLAG_EXEC (1u << 2) /* 0000 0100 */
#define FLAG_HIDDEN (1u << 3) /* 0000 1000 */
int main(void)
{
unsigned a = 12, b = 10;
print_bits8("a = 12", a);
print_bits8("b = 10", b);
print_bits8("a & b", a & b);
print_bits8("a | b", a | b);
print_bits8("a ^ b", a ^ b);
print_bits8("a << 1", a << 1);
print_bits8("a >> 1", a >> 1);
/* --- The four canonical bit operations --- */
unsigned flags = 0;
flags |= FLAG_READ | FLAG_WRITE; /* SET bits */
flags &= ~FLAG_WRITE; /* CLEAR a bit (AND with inverse) */
flags ^= FLAG_EXEC; /* TOGGLE a bit */
if (flags & FLAG_READ) /* TEST a bit */
puts("\nREAD is set");
print_bits8("flags", flags);
/* --- Undefined behavior to avoid --- */
/* int x = 1 << 31; UB: overflows signed int */
uint32_t ok = 1u << 31; /* fine: unsigned */
printf("1u << 31 = %u\n", ok);
/* int y = 1 << 32; UB: shift count >= width */
/* int z = -8 >> 1; implementation-defined */
/* Promotion surprise: operands smaller than int are promoted. */
unsigned char c = 0;
printf("~c as int = %d (not 255)\n", ~c); /* -1 */
printf("(unsigned char)~c = %u\n", (unsigned char)~c); /* 255 */
/* Useful idioms */
unsigned n = 40;
printf("\nn is %s\n", (n & 1u) ? "odd" : "even"); /* low bit test */
printf("n is %s a power of 2\n",
(n && !(n & (n - 1))) ? "" : "not"); /* power-of-two test */
printf("n / 8 via shift = %u\n", n >> 3);
printf("n * 4 via shift = %u\n", n << 2);
return 0;
}
Clearing a bit, step by step:
flags 0000 1011
FLAG_WRITE 0000 0010
~FLAG_WRITE 1111 1101 <- invert the mask
----------------------- AND
result 0000 1001 <- bit 1 cleared, everything else preserved
Key Takeaways
&masks,|sets,^toggles,~inverts;<<and>>multiply and divide by powers of two.- Set with
x |= MASK, clear withx &= ~MASK, toggle withx ^= MASK, test withif (x & MASK). - Shifting by a count
>=the type's width, or by a negative count, is undefined behavior. - Left-shifting into or past the sign bit of a signed type is undefined; use unsigned types for bit manipulation.
- Integer promotion applies first, so
~and<<onchar/shortoperate on a promotedint.
🧪 Practice
- Write
set_bit,clear_bit,toggle_bit, andtest_bitfunctions taking auint32_tand a bit index. Test each.- Implement a function that counts the set bits in a
uint32_tusingn &= n - 1in a loop, and explain why that clears the lowest set bit.- Write a function that swaps the two 16-bit halves of a
uint32_tusing shifts and masks.- Interview-style: "How do you check whether an unsigned integer is a power of two in one expression?" Hint: think about what
n - 1does to the bit pattern of a power of two.
Assignment and Compound Assignment
Theory
In C, assignment is an expression, not a statement. x = 5 has a value (5) and a type (that of x), which is why if (x = 5) compiles and why chained assignment works:
a = b = c = 0; /* right-associative: a = (b = (c = 0)) */
The result of an assignment is the value after conversion to the left operand's type. So int i; double d = (i = 3.7); gives d == 3.0, not 3.7 — the truncation happens first.
Compound assignment operators (+=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=) combine an operation with assignment. The standard defines a op= b as equivalent to a = a op b except that a is evaluated only once. That exception is the entire point:
arr[compute_index()] += 5; /* compute_index() called ONCE */
arr[compute_index()] = arr[compute_index()] + 5; /* called TWICE -- possibly
different indices! */
For any expression with side effects or nontrivial cost on the left, the compound form is both faster and more correct.
One subtlety: compound assignment performs an implicit conversion back to the left type, which can silently truncate:
char c = 100;
c += 100; /* computed as int 200, then truncated to char -- may be -56 */
-Wconversion flags these.
Examples
#include <stdio.h>
static int call_count = 0;
int index_of(void)
{
call_count++;
printf(" [index_of called, count=%d]\n", call_count);
return 2;
}
int main(void)
{
/* Assignment is an expression with a value. */
int x, y, z;
x = y = z = 10; /* right-associative chain */
printf("x=%d y=%d z=%d\n", x, y, z);
int n;
printf("value of (n = 42) is %d\n", (n = 42));
/* The assignment's value is AFTER conversion. */
int i;
double d = (i = 3.7); /* i becomes 3; d becomes 3.0 */
printf("i=%d d=%g\n", i, d);
/* Compound assignment evaluates the left operand once. */
int arr[5] = {0, 0, 0, 0, 0};
puts("\nCompound form:");
arr[index_of()] += 5; /* one call */
puts("Expanded form:");
call_count = 0;
arr[index_of()] = arr[index_of()] + 5;/* two calls */
/* All compound operators */
int v = 20;
v += 5; printf("\nv += 5 -> %d\n", v); /* 25 */
v -= 3; printf("v -= 3 -> %d\n", v); /* 22 */
v *= 2; printf("v *= 2 -> %d\n", v); /* 44 */
v /= 4; printf("v /= 4 -> %d\n", v); /* 11 */
v %= 3; printf("v %%= 3 -> %d\n", v); /* 2 */
v <<= 4; printf("v <<= 4 -> %d\n", v); /* 32 */
v >>= 2; printf("v >>= 2 -> %d\n", v); /* 8 */
v |= 1; printf("v |= 1 -> %d\n", v); /* 9 */
v &= 12; printf("v &= 12 -> %d\n", v); /* 8 */
v ^= 15; printf("v ^= 15 -> %d\n", v); /* 7 */
/* Silent truncation on the implicit conversion back. */
char c = 100;
c += 100; /* int 200 -> char, may overflow */
printf("\nchar 100 += 100 -> %d\n", c);
return 0;
}
Key Takeaways
- Assignment is an expression whose value is the assigned value after conversion to the left operand's type.
- Assignment is right-associative, making
a = b = c = 0valid. a op= bevaluatesaexactly once, unlikea = a op b— essential when the left side has side effects.- Compound assignment implicitly converts back to the left type, which can truncate silently.
- Enable
-Wconversionto catch narrowing introduced by compound assignment.
🧪 Practice
- Write a function with a side effect that returns an array index, and use it in both
arr[f()] += 1andarr[f()] = arr[f()] + 1. Count the calls.- Demonstrate silent truncation with
char c = 100; c += 100;and then withunsigned char. Explain the difference.- Rewrite a chain of
x = x * 2 + 1style updates using compound operators and confirm identical results.- Interview-style: "What is the value and type of the expression
(char)(i = 300)wheniis anint?" Hint: two conversions happen, in a specific order.
Increment and Decrement
Theory
++ and -- add or subtract one. Each has two forms, and the difference is what the expression evaluates to, not what happens to the variable:
| Form | Name | Expression value | Variable after |
|---|---|---|---|
++x | Prefix | The new value | incremented |
x++ | Postfix | The old value | incremented |
Mnemonic: read the operator position literally. In ++x the increment comes first, so you get the new value. In x++ the increment comes after, so you get the old value.
int x = 5;
int a = ++x; /* x becomes 6, a == 6 */
int y = 5;
int b = y++; /* b == 5, then y becomes 6 */
As a standalone statement, i++; and ++i; are identical — the discarded value is the only difference. (The old advice that ++i is faster applies to C++ iterators with overloaded operators, not to C scalars, where compilers generate identical code.)
The genuinely important rule: modifying an object more than once between sequence points, or reading it for a purpose other than computing its new value, is undefined behavior. These expressions have no defined meaning:
i = i++; /* UB */
i = ++i + i++; /* UB */
arr[i] = i++; /* UB */
printf("%d %d", i++, i++); /* UB */
Different compilers produce different results, and the same compiler may differ between optimization levels. There is no "right answer" to memorize — the correct response is to never write such code. C11 restated these rules in terms of sequencing rather than sequence points, but the practical guidance is unchanged.
The classic legitimate idiom is combining traversal with access:
*dst++ = *src++; /* copy, then advance both pointers */
Here each object is modified once, and the reads compute the pointers' own new values, so it is well-defined.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
/* Prefix vs postfix: same effect on the variable, different value. */
int x = 5;
printf("x=5; ++x yields %d, x is now %d\n", ++x, x); /* 6, 6 */
int y = 5;
printf("y=5; y++ yields %d, y is now %d\n", y++, y); /* 5, 6 */
/* As statements, they are equivalent. */
int i = 0, j = 0;
i++; ++j;
printf("\nafter i++ and ++j: i=%d j=%d\n", i, j); /* 1 1 */
/* The classic pointer-copy idiom (well-defined). */
const char *src = "hello";
char dst[16], *d = dst;
while ((*d++ = *src++) != '\0') /* copy char, advance both, test */
;
printf("copied: %s\n", dst);
/* Correct array fill: one modification per expression. */
int arr[5];
for (int k = 0; k < 5; k++) arr[k] = k * 10;
for (int k = 0; k < 5; k++) printf("%d ", arr[k]);
printf("\n");
/* UNDEFINED BEHAVIOR -- never write these: */
/* int v = 5; v = v++; */
/* int w = 1; w = ++w + w++; */
/* printf("%d %d\n", i++, i++); */
/* while (n < 5) arr[n] = n++ * 10; */
return 0;
}
Evaluation trace:
int x = 5;
int a = ++x;
step 1: x = x + 1 -> x is 6
step 2: value of expr -> 6
step 3: a = 6
int y = 5;
int b = y++;
step 1: value of expr -> 5 (the OLD value is captured)
step 2: y = y + 1 -> y is 6
step 3: b = 5
Key Takeaways
++xyields the new value;x++yields the old value. Both increment the variable.- As standalone statements they are equivalent; in C there is no performance difference for scalars.
- Modifying an object twice in one expression (
i = i++) is undefined behavior with no meaningful "answer." - Reading a variable in the same expression where it is modified, for an unrelated purpose (
arr[i] = i++), is also undefined. *dst++ = *src++is a well-defined and idiomatic copy loop, because each object is modified once.
🧪 Practice
- For
int x = 3;print the value and the resultingxfor each ofx++,++x,x--,--x, resetting between each.- Write
i = i++;and compile it with GCC and Clang at-O0and-O2. Compare results and note that all four may differ.- Implement
strcpyyourself using the*d++ = *s++idiom, then rewrite it with explicit indices and compare readability.- Interview-style: "What does
a[i] = i++;evaluate to?" Hint: the correct answer is not a number — name the language rule it violates.
Ternary Conditional Operator
Theory
The conditional operator ? : is C's only ternary operator (it takes three operands) and its only expression-level conditional. Where if is a statement that performs actions, ? : is an expression that produces a value:
result = condition ? value_if_true : value_if_false;
Two properties make it more than syntactic sugar:
1. Only one branch is evaluated. There is a sequence point after the condition, so the untaken branch's side effects never happen. This makes guarded expressions safe:
int len = (s != NULL) ? strlen(s) : 0; /* strlen never called on NULL */
2. It works where statements cannot — in initializers, in const initialization, inside macro bodies, and in function arguments:
const int limit = is_debug ? 10 : 1000; /* an if-statement cannot do this */
printf("%d item%s\n", n, n == 1 ? "" : "s");
The result type follows the usual arithmetic conversions applied to the two branches, which can surprise you: flag ? 1 : 2.0 has type double, so the result is 1.0 even when the condition is true.
Style caution: the operator is right-associative, so nesting works (a ? b : c ? d : e parses as a ? b : (c ? d : e)), but deeply nested ternaries become unreadable fast. Two levels is a reasonable ceiling; beyond that use if/else or a lookup table.
Note also that its precedence is very low — lower than arithmetic and comparison but higher than assignment — so parenthesize when embedding it in a larger expression.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
int a = 10, b = 20;
/* Basic use: an expression, so it can initialize. */
int max = (a > b) ? a : b;
printf("max = %d\n", max);
/* Works where a statement cannot: const initialization. */
const int retries = (a > 5) ? 3 : 10;
printf("retries = %d\n", retries);
/* Inline pluralization -- a very common idiom. */
for (int n = 0; n <= 2; n++)
printf("%d file%s\n", n, n == 1 ? "" : "s");
/* Safe guarded evaluation: strlen is skipped when s is NULL. */
const char *s = NULL;
size_t len = (s != NULL) ? strlen(s) : 0;
printf("len = %zu\n", len);
/* Result type follows the usual arithmetic conversions. */
printf("\n1 : 2.0 -> %g (double, even though the condition is true)\n",
1 ? 1 : 2.0);
printf("sizeof (1 ? 'a' : 0) = %zu\n", sizeof (1 ? 'a' : 0)); /* int */
/* Nesting: readable at two levels, not beyond. */
int score = 85;
const char *grade = score >= 90 ? "A"
: score >= 80 ? "B"
: score >= 70 ? "C"
: "F";
printf("\ngrade = %s\n", grade);
/* Precedence trap: ?: binds looser than +, so parenthesize. */
int x = 5;
printf("1 + (x > 3 ? 10 : 20) = %d\n", 1 + (x > 3 ? 10 : 20)); /* 11 */
/* Also usable as an lvalue selector via pointers. */
int p = 1, q = 2;
*((a > b) ? &p : &q) = 99; /* assigns to q, since a < b */
printf("p=%d q=%d\n", p, q);
return 0;
}
Comparison with if:
if / else | ? : | |
|---|---|---|
| Kind | Statement | Expression |
| Produces a value | No | Yes |
| Usable in an initializer | No | Yes |
| Can contain multiple statements | Yes | No |
| Best for | Control flow with actions | Choosing between two values |
Key Takeaways
? :is an expression that yields a value, so it works in initializers, arguments, and macro bodies whereifcannot.- Only the selected branch is evaluated; a sequence point after the condition makes NULL guards safe.
- The result type comes from the usual arithmetic conversions of both branches —
1 ? 1 : 2.0is1.0. - It is right-associative, so chained ternaries work, but keep nesting shallow for readability.
- Its low precedence means you should parenthesize it inside larger expressions.
🧪 Practice
- Rewrite three small
if/elseassignments as ternary expressions, and one ternary chain back intoif/else. Judge which reads better in each case.- Write
printf("%d item%s\n", n, ...)handling singular/plural, then extend it to handle 0 as "no items" with nested ternaries.sizeof (1 ? 'a' : 0)andsizeof (1 ? 'a' : 0.0)and explain both results.- Interview-style: "Can the conditional operator be used on the left-hand side of an assignment?" Hint: not directly in C — but consider what happens if the branches yield pointers.
Comma Operator
Theory
The comma operator evaluates its left operand, discards the result, then evaluates its right operand and yields that value. There is a sequence point between the two, so the left side's side effects are complete before the right side begins.
x = (a, b); /* evaluates a (discarding it), then assigns b to x */
The left operand is therefore useful only for its side effects. Its main legitimate use is in for loops, where the syntax allows only one expression in the init and increment slots:
for (i = 0, j = n - 1; i < j; i++, j--) /* two counters, one loop */
The essential confusion to clear up: most commas in C are not the comma operator. They are separators, and they have different meanings:
| Context | Is it the operator? |
|---|---|
f(a, b) — function arguments | No, separator |
int a, b; — declarations | No, separator |
{1, 2, 3} — initializer lists | No, separator |
enum { A, B } — enumerators | No, separator |
for (a, b; c; d, e) | Yes, operator |
x = (a, b); | Yes, operator |
Because of this, f(a, b) passes two arguments while f((a, b)) passes one — the value of b. The extra parentheses change the meaning entirely.
The comma operator has the lowest precedence of any C operator, lower even than assignment, which is why x = a, b; parses as (x = a), b;.
Outside for loops and a few macro idioms, the comma operator is rarely worth using: it hides side effects and makes code harder to read.
Examples
#include <stdio.h>
int main(void)
{
/* The value of a comma expression is the RIGHT operand. */
int x = (1, 2, 3);
printf("x = %d\n", x); /* 3 */
/* Precedence: comma is lower than assignment. */
int y;
y = 1, 2; /* parses as (y = 1), 2 */
printf("y = %d\n", y); /* 1 */
int z = (1, 2); /* parentheses needed to get 2 */
printf("z = %d\n", z); /* 2 */
/* The legitimate use: multiple init/increment expressions in a for loop. */
puts("\nReversing an array with two counters:");
int arr[] = {1, 2, 3, 4, 5, 6};
int n = (int)(sizeof arr / sizeof arr[0]);
for (int i = 0, j = n - 1; i < j; i++, j--) {
int t = arr[i]; arr[i] = arr[j]; arr[j] = t;
}
for (int i = 0; i < n; i++) printf("%d ", arr[i]);
printf("\n");
/* Separator vs operator: these are different calls. */
printf("\ntwo args: ");
printf("%d %d\n", 1, 2); /* comma = separator */
printf("one arg: ");
printf("%d\n", (1, 2)); /* comma = operator; prints 2 */
/* Sequence point: left side completes before right side. */
int i = 0;
int v = (i++, i * 10); /* i becomes 1, then v = 10 */
printf("\ni=%d v=%d\n", i, v);
return 0;
}
A macro idiom where the comma operator earns its place:
/* Log the access and return the element, as a single expression. */
#define TRACED_GET(arr, i) \
(fprintf(stderr, "get[%d]\n", (i)), (arr)[(i)])
int value = TRACED_GET(data, 3); /* logs, then yields data[3] */
Key Takeaways
- The comma operator evaluates left, discards it, evaluates right, and yields the right value.
- There is a sequence point between the operands, so left-side side effects complete first.
- It has the lowest precedence in C — lower than assignment — so
x = a, bmeans(x = a), b. - Commas in function calls, declarations, and initializer lists are separators, not the operator.
- Its main legitimate use is multiple expressions in a
forloop's init and increment clauses.
🧪 Practice
- Print the values of
(1, 2, 3), and ofyaftery = 1, 2;versusy = (1, 2);. Explain each.- Write a
forloop that walks two indices toward each other using the comma operator, and rewrite it without the comma operator. Compare clarity.- Call
printf("%d\n", (1, 2))andprintf("%d %d\n", 1, 2)and explain why the first prints one value.- Interview-style: "How many arguments does
f((a, b), c)pass?" Hint: count the separators at the top level of the argument list.
Precedence and Associativity
Theory
When an expression contains several operators, two rules decide the grouping:
- Precedence determines which operator binds more tightly.
a + b * cisa + (b * c)because*outranks+. - Associativity breaks ties among equal precedence.
a - b - cis(a - b) - cbecause-is left-associative.
Neither rule says anything about evaluation order. f() + g() groups as one addition, but the compiler may call g() first. Precedence is about parsing; order of evaluation is (mostly) unspecified.
The full table, highest precedence first:
| Level | Operators | Associativity |
|---|---|---|
| 1 | () [] . -> postfix ++ -- compound literals | Left to right |
| 2 | prefix ++ -- + - ! ~ * & sizeof casts _Alignof | Right to left |
| 3 | * / % | Left to right |
| 4 | + - | Left to right |
| 5 | << >> | Left to right |
| 6 | < <= > >= | Left to right |
| 7 | == != | Left to right |
| 8 | & | Left to right |
| 9 | ^ | Left to right |
| 10 | | | Left to right |
| 11 | && | Left to right |
| 12 | || | Left to right |
| 13 | ?: | Right to left |
| 14 | = += -= *= … | Right to left |
| 15 | , | Left to right |
Rather than memorizing all fifteen levels, internalize the handful that actually cause bugs:
1. Bitwise operators bind looser than comparison. This is C's most notorious precedence wart:
if (flags & MASK == 0) /* parses as flags & (MASK == 0) -- WRONG */
if ((flags & MASK) == 0) /* what you meant */
2. Shift binds looser than arithmetic.
x << 1 + 2 /* is x << 3, not (x << 1) + 2 */
3. * and ++ on pointers. Both are level 2, right-to-left, but postfix ++ is level 1:
*p++ /* is *(p++) -- dereference the old p, then advance */
(*p)++ /* increment the pointed-to value */
++*p /* same as ++(*p) */
4. . and -> bind tighter than almost everything, so *p.f is *(p.f), not (*p).f. That is exactly why -> exists.
5. Assignment is right-associative and very low, so a = b = c works and x = y > 0 assigns the comparison result.
The practical rule used by every serious C style guide: rely on precedence for arithmetic, parenthesize everything else. Compilers agree — -Wparentheses (part of -Wall) warns about the most dangerous cases.
Examples
#include <stdio.h>
#define FLAG_ACTIVE (1u << 2)
int main(void)
{
/* Arithmetic: precedence works as in mathematics. */
printf("2 + 3 * 4 = %d (= 2 + 12)\n", 2 + 3 * 4); /* 14 */
printf("(2 + 3) * 4 = %d\n", (2 + 3) * 4); /* 20 */
/* Associativity for equal precedence. */
printf("\n10 - 4 - 3 = %d (left-assoc: (10-4)-3)\n", 10 - 4 - 3); /* 3 */
int a, b, c;
a = b = c = 7; /* right-assoc: a = (b = (c = 7)) */
printf("a=%d b=%d c=%d\n", a, b, c);
/* TRAP 1: bitwise & binds looser than == */
unsigned flags = FLAG_ACTIVE;
printf("\nflags & FLAG_ACTIVE == 0 -> %d (WRONG: & (== 0))\n",
flags & FLAG_ACTIVE == 0);
printf("(flags & FLAG_ACTIVE) == 0 -> %d (correct)\n",
(flags & FLAG_ACTIVE) == 0);
/* TRAP 2: shift binds looser than + */
int x = 1;
printf("\nx << 1 + 2 = %d (= x << 3)\n", x << 1 + 2); /* 8 */
printf("(x << 1) + 2 = %d\n", (x << 1) + 2); /* 4 */
/* TRAP 3: pointer dereference and increment */
int arr[] = {10, 20, 30};
int *p = arr;
printf("\n*p++ = %d, p now points at %d\n", *p++, *p); /* 10, 20 */
p = arr;
printf("(*p)++ = %d, arr[0] is now %d\n", (*p)++, arr[0]); /* 10, 11 */
/* TRAP 4: logical && binds tighter than || */
printf("\n1 || 0 && 0 = %d (= 1 || (0 && 0))\n", 1 || 0 && 0); /* 1 */
/* Evaluation ORDER is not precedence: this is unspecified. */
/* printf("%d %d\n", f(), g()); which runs first? unspecified */
return 0;
}
Parse tree for the classic trap:
flags & MASK == 0
== has HIGHER precedence than &, so it groups as:
&
/ \
flags ==
/ \
MASK 0 <- MASK == 0 is evaluated first (usually 0),
then flags & 0, which is always 0.
Key Takeaways
- Precedence decides grouping; associativity breaks ties; neither determines evaluation order.
- Bitwise
&,^,|bind looser than==and!=— always parenthesize(x & MASK) == 0. - Shifts bind looser than
+and-:x << 1 + 2isx << 3. *p++dereferences then advances; use(*p)++to increment the pointed-to value.- Rely on precedence for ordinary arithmetic and parenthesize everything else;
-Wparenthesescatches the worst cases.
🧪 Practice
- Predict the value of
2 + 3 * 4 - 6 / 3,1 << 2 + 3, and5 & 3 == 3before running them, then verify.- Write
if (flags & MASK == 0)and compile with-Wall. Read the warning and fix it.- For
int arr[] = {1,2,3}; int *p = arr;predict and verify*p++,*++p,++*p, and(*p)++, resettingpeach time.- Interview-style: "In
f() + g() * h(), which function is called first?" Hint: distinguish the shape of the expression tree from the order the compiler chooses to evaluate leaves.
<a id="24-conversions-and-numeric-behavior"></a>
2.4 Conversions and Numeric Behavior
C converts between types constantly and silently. These rules are mechanical and knowable, and they explain a large fraction of otherwise baffling bugs.
Implicit Conversions and Integer Promotion
Theory
Whenever an expression mixes types, C converts operands to a common type before computing. Some of these conversions are lossless; others silently discard information. Nothing warns you by default.
The first rule to learn is integer promotion, which applies to every small integer type in every arithmetic context:
Any object of a type whose rank is lower than
int— that is,char,signed char,unsigned char,short,unsigned short,_Bool, and bit-fields — is converted tointifintcan represent all its values; otherwise tounsigned int.
This happens before any arithmetic, before shifts, before ~. On a typical 32-bit-int machine, unsigned char and unsigned short both promote to signed int, because a 32-bit int can represent every 8-bit and 16-bit unsigned value. That has visible consequences:
unsigned char a = 200, b = 100;
unsigned char c = a + b; /* computed as int 300, then truncated to 44 */
printf("%d\n", a + b); /* prints 300, not 44 -- the expression is int */
Promotion is why sizeof(char) == 1 but sizeof('a') and sizeof(c + 0) are 4.
The other implicit conversions:
| Conversion | Behavior |
|---|---|
| Narrower integer → wider integer | Value preserved (sign-extended if signed) |
| Wider → narrower integer | Truncates to the low bits; for signed, implementation-defined before C23, wraps in C23 |
| Signed → unsigned | Adds 2^N if negative; well-defined modular result |
| Integer → floating | May lose precision for large integers |
| Floating → integer | Truncates toward zero; UB if out of range |
double → float | Rounds; may become infinity |
Anything → _Bool | 0 stays 0, everything else becomes 1 |
The float-to-integer rule is the one people forget: (int)3.99 is 3, not 4. There is no rounding — use round(), floor(), or ceil() if you want it.
Examples
#include <stdio.h>
#include <limits.h>
int main(void)
{
/* --- Integer promotion in action --- */
unsigned char a = 200, b = 100;
printf("a + b as expression = %d\n", a + b); /* 300 -- it's an int */
unsigned char sum = a + b;
printf("stored in u.char = %d\n", sum); /* 44 -- truncated */
printf("sizeof(a) = %zu\n", sizeof a); /* 1 */
printf("sizeof(a + 0) = %zu\n", sizeof(a + 0)); /* 4 -- promoted */
/* char arithmetic is int arithmetic */
char c1 = 'A';
printf("\n'A' + 1 = %d, as char = %c\n", c1 + 1, (char)(c1 + 1));
/* --- Narrowing truncates --- */
int big = 300;
char small = (char)big; /* keeps low 8 bits: 300 & 0xFF = 44 */
printf("\n(char)300 = %d\n", small);
long l = 4294967296L; /* 2^32 */
int i = (int)l; /* low 32 bits are all zero */
printf("(int)4294967296 = %d\n", i); /* 0 */
/* --- Signed to unsigned is modular, not a reinterpretation error --- */
int neg = -1;
unsigned u = neg; /* -1 + 2^32 = 4294967295 */
printf("\n(unsigned)-1 = %u\n", u);
/* --- Float to int TRUNCATES --- */
printf("\n(int)3.99 = %d\n", (int)3.99); /* 3, not 4 */
printf("(int)-3.99 = %d\n", (int)-3.99); /* -3, not -4 */
printf("(int)(3.99 + 0.5) = %d (manual rounding)\n", (int)(3.99 + 0.5));
/* --- Integer to float can lose precision --- */
int precise = 16777217; /* 2^24 + 1 */
float f = precise; /* float has only 24 significand bits */
printf("\nint 16777217 -> float -> int = %d\n", (int)f); /* 16777216 */
/* --- Anything to _Bool normalizes --- */
_Bool t = 42;
printf("\n(_Bool)42 = %d\n", t); /* 1 */
return 0;
}
Promotion diagram:
unsigned char a = 200; bits: 1100 1000 (1 byte)
unsigned char b = 100; bits: 0110 0100 (1 byte)
a + b:
step 1 promote a to int: 0000 0000 ... 1100 1000 = 200
step 2 promote b to int: 0000 0000 ... 0110 0100 = 100
step 3 add as int: 0000 0001 ... 0010 1100 = 300
step 4 IF stored back into unsigned char: keep low 8 bits
0010 1100 = 44
Key Takeaways
- Integer promotion converts every type narrower than
inttointbefore arithmetic, shifts, or~. unsigned char + unsigned charis computed asint; truncation happens only on assignment back to a narrow type.- Narrowing keeps the low-order bits; signed-to-unsigned conversion adds
2^Nand is well-defined. - Float-to-integer conversion truncates toward zero and is undefined if the value does not fit.
- A 32-bit
floatcannot represent everyintpast2^24; usedoublewhen converting large integers.
🧪 Practice
sizeof(char),sizeof(c), andsizeof(c + 0)for achar c, and explain the three results.- Add two
unsigned charvalues that sum past 255, printing both the expression result and the value stored back into anunsigned char.- Find the smallest positive integer that cannot be represented exactly as a
float, by converting and converting back in a loop.- Interview-style: "Why does
(int)(x + 0.5)fail as a rounding function for negativex?" Hint: work throughx = -2.7step by step.
Usual Arithmetic Conversions
Theory
When a binary operator has operands of different types, C applies the usual arithmetic conversions to find a common type. The algorithm is mechanical, and knowing it turns "mysterious" results into predictable ones.
The order of checks:
1. If either operand is long double -> convert the other to long double
2. Else if either is double -> convert the other to double
3. Else if either is float -> convert the other to float
4. Otherwise (both are integers):
a. Apply INTEGER PROMOTION to both operands
b. If both now have the same type -> done
c. If both are signed, or both unsigned -> convert to the higher rank
d. If the UNSIGNED type has rank >= the signed type's rank
-> convert the signed to unsigned
e. Else if the SIGNED type can represent all values of the unsigned type
-> convert the unsigned to signed
f. Else -> convert BOTH to the unsigned version of the signed type
Integer rank order: _Bool < char < short < int < long < long long.
Step 4d is where the classic bug lives. int vs unsigned int on a typical platform: same rank, unsigned wins, so the int becomes unsigned, and a negative value becomes enormous.
Note also that the result of step 4d–4f depends on the platform's type sizes. On LP64, int vs unsigned int converts to unsigned int (rule d), but int vs unsigned short promotes the unsigned short to int first (rule a), so both end up int — no surprise. Change to a 16-bit-int platform and that second case behaves differently.
Examples
#include <stdio.h>
#include <limits.h>
int main(void)
{
/* Rule 2: one double makes both double. */
int i = 7;
double d = 2.0;
printf("7 / 2.0 = %g (int converted to double)\n", i / d); /* 3.5 */
printf("7 / 2 = %d (both int)\n", 7 / 2); /* 3 */
/* Rule 4d: unsigned wins at equal rank -- the classic trap. */
int s = -1;
unsigned u = 1;
printf("\n(-1 < 1u) = %d\n", s < u); /* 0 */
printf("(-1 converted) = %u\n", (unsigned)s); /* 4294967295 */
/* Rule 4e: a wider signed type can represent the unsigned one. */
long ls = -1;
unsigned int ui = 1;
printf("\n(-1L < 1u) on LP64 = %d (unsigned int fits in long)\n", ls < ui);
/* Rule 4a: promotion first can eliminate the mismatch entirely. */
int si = -1;
unsigned short us = 1;
printf("(-1 < (unsigned short)1) = %d (us promoted to int)\n", si < us); /* 1 */
/* A realistic bug: comparing a signed count against strlen. */
int count = -1;
size_t len = 5; /* size_t is unsigned */
if (count < (int)len) puts("\ncorrect: cast to signed");
if (count < len) puts("this does NOT print: count became huge");
/* Mixed-width integer arithmetic */
printf("\nsizeof(int + long) = %zu\n", sizeof(1 + 1L));
printf("sizeof(int + long long) = %zu\n", sizeof(1 + 1LL));
printf("sizeof(char + char) = %zu (both promoted to int)\n",
sizeof((char)1 + (char)1));
printf("sizeof(float + double) = %zu\n", sizeof(1.0f + 1.0));
return 0;
}
Decision flowchart:
a OP b with different types
|
v
any long double? --yes--> both long double
| no
v
any double? -------yes--> both double
| no
v
any float? --------yes--> both float
| no
v
promote both to at least int
|
v
same type now? ----yes--> done
| no
v
same signedness? --yes--> convert to the higher rank
| no
v
unsigned rank >= signed rank? --yes--> convert signed -> UNSIGNED (the trap)
| no
v
signed type covers all unsigned values? --yes--> convert unsigned -> signed
| no
v
both -> unsigned version of the signed type
Key Takeaways
- Floating types win over integers;
long double>double>float. - For integers, promotion to
inthappens first, then the higher-rank type wins. - At equal rank, unsigned beats signed — this converts negative values to huge positives.
- A wider signed type can absorb a narrower unsigned type, avoiding the trap (
longvsunsigned inton LP64). - Enable
-Wsign-compareand-Wconversion, and cast explicitly whenever operand types differ in signedness.
🧪 Practice
- For each pair —
int/unsigned,long/unsigned int,int/unsigned short,char/char— printsizeofof the sum and predict the common type first.- Write a loop comparing a signed index against
strlen(s)that fails, then fix it three ways (cast, usesize_t, restructure the condition).- Demonstrate a case where
a < bandb < care both true buta < cis false, using mixed signedness.- Interview-style: "Why is
-1 < sizeof(int)false?" Hint: name the specific step of the usual arithmetic conversions that applies.
Explicit Casts
Theory
A cast (type)expression performs an explicit conversion. Its purpose is to say "I know these types differ, and I mean this." Used well, it documents intent; used carelessly, it silences the compiler while the bug survives.
The legitimate uses:
1. Forcing floating-point arithmetic.
double avg = (double)total / count; /* without the cast: integer division */
2. Documenting an intentional narrowing.
uint8_t low = (uint8_t)(value & 0xFF); /* deliberate truncation */
3. Converting void * to a concrete type where required (in C, mainly for function-pointer or qsort comparator use, since object-pointer conversions are implicit).
4. Discarding a value explicitly.
(void)unused_parameter;
The dangerous uses:
Casting away const or volatile. The cast compiles; writing to a genuinely const object is undefined behavior.
Casting between unrelated pointer types. This can violate alignment requirements and the strict aliasing rule, both of which are undefined behavior that optimizers actively exploit:
float f = 1.5f;
int *p = (int *)&f; /* strict aliasing violation */
printf("%d\n", *p); /* UB -- may print anything, may be optimized away */
The correct way to reinterpret bits is memcpy (which compilers optimize into a single move) or a union.
Casting a pointer to a smaller integer type. Use uintptr_t, never int.
A useful discipline: every cast should have a reason you can state in one sentence. If the reason is "it made the warning go away," the cast is probably hiding a real problem.
Note that C has only this one cast syntax — there is no static_cast/reinterpret_cast distinction as in C++, so the reader cannot tell intent from syntax. Comments matter more.
Examples
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdlib.h>
int main(void)
{
/* 1. Forcing floating-point division -- cast BEFORE dividing. */
int total = 7, count = 2;
printf("no cast: %g\n", (double)(total / count)); /* 3 -- too late */
printf("correct cast: %g\n", (double)total / count); /* 3.5 */
/* 2. Documented, intentional narrowing. */
uint32_t value = 0xDEADBEEF;
uint8_t low = (uint8_t)(value & 0xFFu); /* explicitly want one byte */
printf("\nlow byte = 0x%02X\n", low);
/* 3. Reinterpreting bits: memcpy, not a pointer cast. */
float f = 1.5f;
uint32_t bits;
memcpy(&bits, &f, sizeof bits); /* well-defined, and optimized
to a single register move */
printf("float 1.5 bit pattern = 0x%08X\n", bits);
/* uint32_t *bad = (uint32_t *)&f; strict aliasing violation -- UB */
/* 4. Pointer-to-integer requires uintptr_t. */
int v = 5;
uintptr_t addr = (uintptr_t)&v; /* correct */
/* int addr_bad = (int)&v; truncates on 64-bit */
printf("address = 0x%" PRIxPTR "\n", addr);
/* 5. Explicitly discarding a value. */
(void)printf("discarded return value\n");
/* 6. Casting away const is legal syntax and dangerous semantics. */
const int locked = 10;
int *sneaky = (int *)&locked; /* compiles fine */
/* *sneaky = 20; */ /* UNDEFINED BEHAVIOR */
(void)sneaky;
/* 7. In C you do NOT cast malloc's result. */
int *arr = malloc(10 * sizeof *arr); /* void* converts implicitly */
if (arr) { arr[0] = 1; free(arr); }
return 0;
}
Cast checklist:
| Cast | Verdict |
|---|---|
(double)a / b | Good — forces float division |
(uint8_t)(x & 0xFF) | Good — documents intended truncation |
(void)unused | Good — documents deliberate discard |
(int *)malloc(...) | Unnecessary in C; can hide a missing header |
(char *)&some_int | Legal (char * may alias anything) but check intent |
(float *)&some_int | Bad — strict aliasing violation |
(int *)const_ptr then write | Bad — undefined behavior |
(int)pointer | Bad — use uintptr_t |
Key Takeaways
- A cast documents an intentional conversion; it never makes an invalid operation valid.
- Cast before the operation for float division:
(double)a / b, not(double)(a / b). - Reinterpret bit patterns with
memcpyor a union, never by casting between unrelated pointer types. - Casting away
constcompiles but writing through the result is undefined behavior. - Do not cast
malloc's return in C; useuintptr_twhen a pointer must become an integer.
🧪 Practice
- Compute an average of two
intvalues as adoublein three ways — no cast, cast after the division, cast before — and explain all three results.- Extract the sign, exponent, and mantissa bits of a
floatusingmemcpyinto auint32_t. Verify against a known value.- Cast away
conston aconstglobal and attempt a write. Compile at-O0and-O2; describe what happens in each case.- Interview-style: "Why is
(float *)&my_intundefined behavior when(char *)&my_intis not?" Hint: look up which types are allowed to alias any object.
Integer Overflow and Wraparound
Theory
An integer type holds a finite range. Exceeding it is overflow, and C treats the two signedness classes completely differently:
| Signed overflow | Unsigned overflow | |
|---|---|---|
| Standard says | Undefined behavior | Wraps modulo 2^N |
| Practical result | Anything — including deleted code | Predictable wraparound |
| Can you check afterward? | No, the check itself is UB | Yes |
Unsigned wraparound is a defined, useful feature: hash functions, checksums, and circular buffers rely on it.
Signed overflow being undefined is not an oversight — it lets the optimizer assume x + 1 > x and that loop counters do not wrap, which enables real optimizations. The cost is that overflow-check code written the obvious way gets deleted:
int check(int a, int b) {
int sum = a + b; /* if this overflows, the program has NO meaning */
if (sum < a) return -1; /* optimizer: "cannot happen" -> may delete this */
return sum;
}
The correct approach is to check before overflowing:
if (a > INT_MAX - b) return -1; /* no overflow occurs in this test */
int sum = a + b;
Or use the compiler builtins, which are the practical modern answer:
int sum;
if (__builtin_add_overflow(a, b, &sum)) { /* handle overflow */ }
GCC and Clang both provide __builtin_add_overflow, __builtin_sub_overflow, and __builtin_mul_overflow. C23 adds standard equivalents in <stdckdint.h> (ckd_add, ckd_sub, ckd_mul).
Three specific traps worth memorizing:
INT_MIN / -1andINT_MIN % -1overflow (the magnitude exceedsINT_MAX) — undefined, and typically SIGFPE.-INT_MINoverflows for the same reason.abs(INT_MIN)is undefined.
Detect overflow at runtime during development with -fsanitize=signed-integer-overflow (part of -fsanitize=undefined).
Examples
#include <stdio.h>
#include <limits.h>
#include <stdbool.h>
/* WRONG: the overflow happens before the check, so the check is meaningless. */
bool bad_would_overflow(int a, int b)
{
int sum = a + b; /* UB right here if it overflows */
return sum < a; /* optimizer may assume this is false */
}
/* RIGHT: test the operands without ever overflowing. */
bool safe_add(int a, int b, int *out)
{
if (b > 0 && a > INT_MAX - b) return false; /* would overflow high */
if (b < 0 && a < INT_MIN - b) return false; /* would overflow low */
*out = a + b;
return true;
}
bool safe_mul(int a, int b, int *out)
{
if (a > 0 && b > 0 && a > INT_MAX / b) return false;
if (a > 0 && b < 0 && b < INT_MIN / a) return false;
if (a < 0 && b > 0 && a < INT_MIN / b) return false;
if (a < 0 && b < 0 && a < INT_MAX / b) return false;
*out = a * b;
return true;
}
int main(void)
{
/* Unsigned wraparound is DEFINED and predictable. */
unsigned int u = UINT_MAX;
printf("UINT_MAX + 1 = %u (defined: wraps to 0)\n", u + 1);
unsigned char b = 250;
b += 10;
printf("250 + 10 as unsigned char = %u (wraps to 4)\n", b);
/* Signed overflow is UNDEFINED -- do not rely on the output below. */
printf("\nINT_MAX = %d\n", INT_MAX);
/* int overflowed = INT_MAX + 1; UB: may print INT_MIN, may not */
/* Safe arithmetic */
int result;
printf("\nsafe_add(INT_MAX, 1) = %s\n",
safe_add(INT_MAX, 1, &result) ? "ok" : "OVERFLOW detected");
printf("safe_add(100, 200) = %s (%d)\n",
safe_add(100, 200, &result) ? "ok" : "overflow", result);
printf("safe_mul(100000, 100000) = %s\n",
safe_mul(100000, 100000, &result) ? "ok" : "OVERFLOW detected");
/* Compiler builtins: the practical modern approach. */
#if defined(__GNUC__) || defined(__clang__)
int sum;
if (__builtin_add_overflow(INT_MAX, 1, &sum))
printf("\nbuiltin: overflow detected\n");
#endif
/* The INT_MIN traps */
printf("\nINT_MIN = %d\n", INT_MIN);
/* printf("%d\n", INT_MIN / -1); UB: magnitude exceeds INT_MAX */
/* printf("%d\n", -INT_MIN); UB: same reason */
(void)bad_would_overflow(1, 1);
return 0;
}
Wraparound visualized:
8-bit unsigned: 8-bit signed:
255 -> 0 127 -> -128
+----------+ +------------+
| | | |
254 1 126 -127
| | | |
+---- 0 ---+ +---- 0 -----+
DEFINED: modulo 256 UNDEFINED BEHAVIOR
(the arrow really happens) (the arrow is what hardware
does; the language says nothing)
Key Takeaways
- Unsigned overflow wraps modulo
2^Nand is fully defined; signed overflow is undefined behavior. - Post-hoc overflow checks on signed arithmetic are themselves undefined and may be optimized away.
- Check before the operation (
a > INT_MAX - b) or use__builtin_*_overflow/ C23<stdckdint.h>. INT_MIN / -1,-INT_MIN, andabs(INT_MIN)all overflow and are undefined.- Build with
-fsanitize=undefinedduring development to catch signed overflow at runtime.
🧪 Practice
- Write a program that wraps an
unsigned charpast 255 and anunsigned intpastUINT_MAX. Confirm both behave modulo2^N.- Implement
safe_subandsafe_divin the style ofsafe_add, remembering theINT_MIN / -1case.- Compile a signed-overflow program at
-O0,-O2, and with-fsanitize=undefined. Compare all three outputs.- Interview-style: "Why can't you detect signed overflow by checking whether the result is smaller than the operand?" Hint: the check runs after the program has already lost all defined meaning.
Floating-Point Representation (IEEE 754)
Theory
A floating-point number is stored as three fields — sign, exponent, and significand (mantissa) — encoding the value:
value = (-1)^sign x 1.mantissa x 2^(exponent - bias)
This is binary scientific notation. The leading 1. is implicit for normal numbers (you get one bit free), and the exponent is stored biased so it can represent negatives without a sign of its own.
The two formats you will use:
binary32 (float), 32 bits:
+-+--------+-----------------------+
|S| Exp(8) | Mantissa (23) | bias = 127
+-+--------+-----------------------+
31 30 23 22 0
-> 24 bits of precision (23 stored + 1 implicit) ~ 7 decimal digits
binary64 (double), 64 bits:
+-+-----------+-------------------------------------------+
|S| Exp (11) | Mantissa (52) | bias = 1023
+-+-----------+-------------------------------------------+
63 62 52 51 0
-> 53 bits of precision ~ 15-17 decimal digits
Worked example, the value 1.5 as a float:
1.5 decimal = 1.1 binary = 1.1 x 2^0
sign = 0
exponent = 0 + 127 = 127 = 0111 1111
mantissa = 1000...0 (the fraction after the implicit "1.")
0 01111111 10000000000000000000000 = 0x3FC00000
Special exponent values encode the cases IEEE 754 defines:
| Exponent | Mantissa | Meaning |
|---|---|---|
| All zeros | Zero | ±0 (yes, there are two zeros) |
| All zeros | Nonzero | Subnormal (gradual underflow) |
| All ones | Zero | ±Infinity |
| All ones | Nonzero | NaN (Not a Number) |
The consequence that drives every floating-point bug: only fractions whose denominator is a power of two are exact. 0.5, 0.25, 0.125 are exact. 0.1, 0.2, 0.3 are not — they are repeating fractions in binary, just as 1/3 repeats in decimal. 0.1 stored as a double is actually 0.1000000000000000055511151231257827….
NaN has two properties worth memorizing: it propagates through every arithmetic operation, and it compares unequal to everything, including itself. x != x is a valid (if obscure) NaN test; prefer isnan(x).
Examples
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <math.h>
#include <float.h>
/* Show the raw bit fields of a float. */
static void dump_float(const char *label, float f)
{
uint32_t b;
memcpy(&b, &f, sizeof b); /* well-defined bit reinterpretation */
uint32_t sign = b >> 31;
uint32_t exp = (b >> 23) & 0xFFu;
uint32_t mant = b & 0x7FFFFFu;
printf("%-10s %-12g 0x%08X S=%u E=%3u (2^%d) M=0x%06X\n",
label, (double)f, b, sign, exp, (int)exp - 127, mant);
}
int main(void)
{
puts("label value bits fields");
dump_float("1.0f", 1.0f);
dump_float("1.5f", 1.5f);
dump_float("2.0f", 2.0f);
dump_float("-1.5f", -1.5f);
dump_float("0.1f", 0.1f); /* not exact */
dump_float("0.0f", 0.0f);
dump_float("-0.0f", -0.0f); /* distinct bit pattern from +0.0 */
dump_float("inf", INFINITY);
dump_float("nan", NAN);
/* Exact vs inexact fractions */
printf("\n0.5 = %.20f\n", 0.5); /* exact: 2^-1 */
printf("0.25 = %.20f\n", 0.25); /* exact: 2^-2 */
printf("0.1 = %.20f\n", 0.1); /* NOT exact */
printf("0.3 = %.20f\n", 0.3); /* NOT exact */
/* Zeros compare equal but have different bits. */
printf("\n(0.0 == -0.0) = %d\n", 0.0 == -0.0); /* 1 */
printf("1.0/0.0 = %g\n", 1.0 / 0.0); /* inf */
printf("1.0/-0.0 = %g\n", 1.0 / -0.0); /* -inf */
/* NaN behavior */
double nan_v = NAN;
printf("\nnan == nan = %d (always false)\n", nan_v == nan_v);
printf("nan != nan = %d (always true)\n", nan_v != nan_v);
printf("isnan(nan) = %d\n", isnan(nan_v));
printf("nan + 1 = %g (propagates)\n", nan_v + 1);
/* Machine epsilon: the gap between 1.0 and the next representable value. */
printf("\nDBL_EPSILON = %g\n", DBL_EPSILON);
printf("1.0 + DBL_EPSILON/2 == 1.0 ? %d\n", 1.0 + DBL_EPSILON/2 == 1.0);
return 0;
}
Key Takeaways
- IEEE 754 stores sign, biased exponent, and mantissa;
floatgives ~7 decimal digits,double~15–17. - Only fractions with power-of-two denominators are exact; 0.1, 0.2, and 0.3 are all approximations.
- Special encodings give ±0, ±infinity, subnormals, and NaN.
- NaN compares unequal to everything including itself; test with
isnan, not==. DBL_EPSILONis the gap between 1.0 and the nextdouble— the basis for relative-tolerance comparisons.
🧪 Practice
- Use the
dump_floatfunction to print the bit fields of 3.0, 0.75, and 100.0, and hand-verify one of them against the formula.- Find the largest integer
nsuch that every integer up tonis exactly representable as afloat. Verify by round-tripping.- Show that
0.1f + 0.2f != 0.3fand that0.5f + 0.25f == 0.75f. Explain why one holds and the other does not.- Interview-style: "How many distinct values can a 32-bit
floatrepresent between 1.0 and 2.0?" Hint: the exponent is fixed across that interval — count what the mantissa can do.
Precision Loss and Comparison Pitfalls
Theory
Every floating-point operation rounds its result to the nearest representable value. Individually the error is tiny (at most half an ULP, "unit in the last place"), but errors accumulate, and in specific situations they explode.
Failure mode 1: Equality comparison. Because computed values carry rounding error, == on floating-point results is essentially always wrong:
if (0.1 + 0.2 == 0.3) /* false */
The fix is a tolerance — but the choice of tolerance matters:
- Absolute epsilon (
fabs(a-b) < 1e-9) works only when the magnitudes are near 1. For values around 1e9, 1e-9 is smaller than the gap between adjacent doubles, so it degenerates into==. - Relative epsilon (
fabs(a-b) <= eps * fmax(fabs(a), fabs(b))) scales with magnitude and is correct across ranges — except near zero, where relative comparison breaks down. - Combined: use absolute near zero and relative elsewhere. This is what production code does.
Failure mode 2: Accumulation. Summing many small values in a loop lets rounding error compound. Summing 0.1 ten million times drifts visibly from 1,000,000. Fixes include summing in sorted order (small to large), pairwise summation, or Kahan summation, which tracks the lost low-order bits in a compensation variable.
Failure mode 3: Catastrophic cancellation. Subtracting two nearly equal numbers destroys significant digits. If a and b agree to 15 digits, a - b retains almost no correct digits — the error that was in the last place is now the whole value. The standard example is the quadratic formula when b^2 is much larger than 4ac; the fix is algebraic rearrangement, not more precision.
Failure mode 4: Loop counters. for (double x = 0.0; x != 1.0; x += 0.1) never terminates, because the accumulated x never exactly equals 1.0. Always use an integer loop counter and compute the float from it.
Examples
#include <stdio.h>
#include <math.h>
#include <float.h>
#include <stdbool.h>
#include <stddef.h>
/* Combined absolute/relative comparison -- the practical general solution. */
bool nearly_equal(double a, double b, double rel_eps, double abs_eps)
{
if (a == b) return true; /* handles infinities */
double diff = fabs(a - b);
if (diff <= abs_eps) return true; /* near zero: absolute */
return diff <= rel_eps * fmax(fabs(a), fabs(b)); /* elsewhere: relative */
}
/* Kahan summation: track and reinject the rounding error. */
double kahan_sum(const double *v, size_t n)
{
double sum = 0.0, comp = 0.0; /* comp = running lost bits */
for (size_t i = 0; i < n; i++) {
double y = v[i] - comp; /* add back what was lost */
double t = sum + y; /* this rounds ... */
comp = (t - sum) - y; /* ... and this captures it */
sum = t;
}
return sum;
}
int main(void)
{
/* PITFALL 1: equality */
printf("0.1 + 0.2 == 0.3 : %d\n", 0.1 + 0.2 == 0.3);
printf("nearly_equal(...) : %d\n",
nearly_equal(0.1 + 0.2, 0.3, 1e-9, 1e-12));
/* Absolute epsilon fails at large magnitudes. */
double big1 = 1e9, big2 = 1e9 + 1.0;
printf("\nabsolute 1e-9 on 1e9 : %d (too strict)\n",
fabs(big1 - big2) < 1e-9);
printf("relative 1e-9 on 1e9 : %d\n",
nearly_equal(big1, big2, 1e-9, 1e-12));
/* PITFALL 2: accumulation */
static double vals[1000000];
double naive = 0.0;
for (int i = 0; i < 1000000; i++) vals[i] = 0.1;
for (int i = 0; i < 1000000; i++) naive += 0.1;
printf("\nnaive sum of 1e6 x 0.1 : %.10f\n", naive);
printf("kahan sum : %.10f\n", kahan_sum(vals, 1000000));
printf("exact : 100000.0000000000\n");
/* PITFALL 3: catastrophic cancellation */
double a = 1.0000000000000002, b = 1.0000000000000000;
printf("\na - b = %.20g (only ~1 significant digit survives)\n", a - b);
/* PITFALL 4: float loop counters */
puts("\nBAD: for (double x = 0.0; x != 1.0; x += 0.1) would never end.");
int steps = 0;
for (double x = 0.0; x < 1.0 && steps < 100; x += 0.1) steps++;
printf("x < 1.0 with += 0.1 ran %d times (expected 10)\n", steps);
puts("GOOD: drive the loop with an integer.");
for (int i = 0; i < 10; i++) {
double x = i * 0.1; /* recomputed, not accumulated */
(void)x;
}
printf("integer-driven loop ran exactly 10 times\n");
return 0;
}
Choosing a comparison strategy:
| Situation | Approach |
|---|---|
| Values near 1 | Absolute epsilon (1e-9) is acceptable |
| Values spanning many magnitudes | Relative epsilon |
| Values possibly near zero | Absolute epsilon, or the combined form |
| Comparing to exact zero | fabs(x) < abs_eps |
| Money | Do not use floating point — use integer cents |
| Loop counters | Use an integer and compute the float |
Key Takeaways
- Never compare computed floating-point values with
==; use a tolerance suited to the magnitudes involved. - Absolute epsilon works only near 1; use relative epsilon for large values and a combined test in general code.
- Rounding errors accumulate over long summations — use Kahan summation or sort before summing when accuracy matters.
- Subtracting nearly equal values destroys precision (catastrophic cancellation); fix it algebraically, not with more bits.
- Drive loops with integer counters and compute floating values from them, never by repeated accumulation.
🧪 Practice
- Sum
0.1one million times naively and with Kahan summation; report both errors against the exact value.- Write
nearly_equaland test it on pairs at magnitudes 1e-10, 1, and 1e10. Show where a fixed absolute epsilon fails.- Write the loop
for (double x = 0.0; x != 1.0; x += 0.1)with an iteration cap, and printxeach step to see why it never hits 1.0 exactly.- Interview-style: "Why do financial systems store amounts as integer cents rather than as
doubledollars?" Hint: think about what0.10is in binary, and what happens after a million transactions.