C Language Constructs
C language constructs in the C23 standard
This material targets C23. To verify the examples, use the language mode -std=c23. Official references for language modes and compiler support: GCC Standards and GCC C status.
The GCC C status page includes an important note: C23 mode is the default since GCC 15
. That means newer GCC versions already use C23 by default, but for teaching examples it is still useful to specify -std=c23 explicitly so the behavior stays predictable across different machines.
Structure of a C program
A minimal C program consists of header includes, function declarations or definitions, and the entry point main. The GNU manual explains program structure in detail: Complete Program.
#include <stdio.h>
int square(int x) {
return x * x;
}
int main(void) {
int value = 7;
printf("square(%d) = %d\n", value, square(value));
return 0;
}#include <stdio.h> brings in declarations for standard input and output; the function square is defined before it is called from main; and return 0; sends the exit status back to the operating system. That ordering helps the compiler validate types and call signatures correctly.
In C23 it is especially important to use full function prototypes and avoid older implicit styles. That lowers the risk of mistakes in argument conversions and during linking.
Variables
A variable in C has a type, a name, a scope, and a lifetime. Introductory GNU reference: GNU Variables. In practical code, variables are best initialized immediately, otherwise it is very easy to trigger undefined behavior.
#include <stdio.h>
int main(void) {
int age = 21;
double temperature = 36.6;
char grade = 'A';
printf("age = %d\n", age);
printf("temperature = %.1f\n", temperature);
printf("grade = %c\n", grade);
age = 22;
temperature = 37.0;
grade = 'B';
printf("updated -> age = %d, temperature = %.1f, grade = %c\n", age, temperature, grade);
return 0;
}Expected output:
age = 21
temperature = 36.6
grade = A
updated -> age = 22, temperature = 37.0, grade = BIn this example every variable is explicitly initialized. If you remove the initialization of a local variable and read its value immediately, the program becomes formally incorrect under the language standard.
Data types
Core types are char, int, float, double, plus modifiers such as short, long, and unsigned. Type sizes depend on the platform, so portable code checks them with sizeof.
#include <stdio.h>
int main(void) {
printf("sizeof(char) = %zu\n", sizeof(char));
printf("sizeof(short) = %zu\n", sizeof(short));
printf("sizeof(int) = %zu\n", sizeof(int));
printf("sizeof(long) = %zu\n", sizeof(long));
printf("sizeof(unsigned int) = %zu\n", sizeof(unsigned int));
printf("sizeof(float) = %zu\n", sizeof(float));
printf("sizeof(double) = %zu\n", sizeof(double));
return 0;
}Example output:
sizeof(char) = 1
sizeof(short) = 2
sizeof(int) = 4
sizeof(long) = 8
sizeof(unsigned int) = 4
sizeof(float) = 4
sizeof(double) = 8C23 bool, true, false
In C23 you can use the boolean type directly, without the older dependence on <stdbool.h>. That makes conditions easier to read and closer to the natural logic of the problem domain.
#include <stdio.h>
int main(void) {
bool is_admin = true;
bool has_2fa = false;
printf("is_admin = %d\n", is_admin);
printf("has_2fa = %d\n", has_2fa);
printf("is_admin && !has_2fa = %d\n", is_admin && !has_2fa);
return 0;
}Expected output:
is_admin = 1
has_2fa = 0
is_admin && !has_2fa = 1You can see that boolean expressions still print as 0 and 1, but the code becomes more transparent: the condition describes the meaning directly instead of going through integer surrogates.
C23 nullptr and nullptr_t
C23 introduces nullptr and nullptr_t. That removes the old ambiguity where a null pointer was often written as 0 or NULL, mixing integer and pointer contexts.
#include <stdio.h>
#include <stddef.h>
int main(void) {
int *p = nullptr;
nullptr_t np = nullptr;
printf("p == nullptr -> %d\n", p == nullptr);
printf("np == nullptr -> %d\n", np == nullptr);
return 0;
}Expected output:
p == nullptr -> 1
np == nullptr -> 1This notation is useful in code review and maintenance because the code itself makes it clear that the author means a null pointer, not a number.
size_t: sizes and indexes
C uses size_t for sizes and indexes. The GNU docs say explicitly that it is always an unsigned integer type
for the result of sizeof: see Type Size.
The practical consequence is simple: if you count elements, track buffer lengths, measure memory blocks, or index arrays, use size_t instead of int.
#include <stdio.h>
int main(void) {
int numbers[] = {10, 20, 30, 40, 50};
size_t n = sizeof(numbers) / sizeof(numbers[0]);
size_t i;
int sum = 0;
for (i = 0; i < n; i++) {
sum += numbers[i];
}
printf("n = %zu\n", n);
printf("sum = %d\n", sum);
return 0;
}Expected output:
n = 5
sum = 150Console output: the printf function
A printf format string consists of ordinary characters and conversion specifiers. The GNU libc manual phrases it this way: Characters in the template string ... are printed as-is
, while conversion specifiers define how arguments are formatted. Source: Output Conversion Syntax.
The man page gives the syntax explicitly: %[argument$][flags][width][.precision][length modifier]conversion
. That is the most practical way to read any non-trivial format string: printf(3).
Breakdown of the parts:
argument$: positional numbering of arguments, for example%2$d. Useful in localization and in formats where the same argument is reused.flags: behavior modifiers.-left-aligns,+always prints the sign for signed numbers, space prints a blank instead of+for positive numbers,#enables alternate forms, and0uses zero padding.width: the minimum field width. It can be a literal number or*when the width comes from anintargument..precision: precision. For integers it is a minimum digit count, for floating-point formats it is the number of digits after the decimal point or the number of significant digits, and for strings it is the maximum number of characters.length modifier: the argument size. Common ones arehh,h,l,ll,j,z,t, andL.conversion: the representation kind. Common conversions ared/i,u,o,x/X,f/F,e/E,g/G,a/A,c,s,p,n, and%.
The glibc docs give %n its own special semantics: it stores the number of characters printed so far
and prints nothing. Source: Other Output Conversions.
It is critical to match the format to the exact argument type. A wrong specifier in a varargs call can lead to undefined behavior.
#include <inttypes.h>
#include <stdio.h>
int main(void) {
int i = -42;
unsigned int u = 42;
double pi = 3.1415926535;
char text[] = "C23";
size_t n = 123;
long long big = 9223372036854775807LL;
intmax_t jm = -9000;
void *ptr = text;
int printed = 0;
printf("signed with sign+zero: [%+08d]\n", i);
printf("unsigned left aligned: [%-10u]\n", u);
printf("hex with #: [%#x]\n", u);
printf("oct with #: [%#o]\n", u);
printf("fixed precision: [%.3f]\n", pi);
printf("string precision: [%.*s]\n", 2, text);
printf("size_t with %%zu: [%zu]\n", n);
printf("long long with %%lld: [%lld]\n", big);
printf("intmax_t with %%jd: [%jd]\n", jm);
printf("pointer %%p: [%p]\n", ptr);
printf("percent sign: [%%]\n");
printf("abc%nXYZ\n", &printed);
printf("printed before %%n = %d\n", printed);
return 0;
}Expected output:
signed with sign+zero: [-0000042]
unsigned left aligned: [42 ]
hex with #: [0x2a]
oct with #: [052]
fixed precision: [3.142]
string precision: [C2]
size_t with %zu: [123]
long long with %lld: [9223372036854775807]
intmax_t with %jd: [-9000]
pointer %p: [0x...]
percent sign: [%]
abcXYZ
printed before %n = 3Constants
In C, three main forms are common: const for typed runtime constants, #define for preprocessor substitution, and enum for named integer values. They are different tools with different responsibilities.
Formal rules
- A
const-qualified object is not a modifiable lvalue: you cannot modify it through that lvalue. - Enumerators are integer constant expressions and can be used, for example, in
caselabels. #defineworks during preprocessing and does not create a typed language object.- In contexts that require a constant expression, the value must satisfy the standard rules for constant expressions.
Source: N3096 (C23 draft).
#include <stdio.h>
#define MAX_USERS 100
enum Weekday {
MONDAY = 1,
TUESDAY,
WEDNESDAY
};
int main(void) {
const double pi = 3.1415926535;
int users = MAX_USERS;
enum Weekday day = TUESDAY;
printf("pi = %.5f\n", pi);
printf("MAX_USERS = %d\n", users);
printf("TUESDAY = %d\n", day);
return 0;
}Expected output:
pi = 3.14159
MAX_USERS = 100
TUESDAY = 2If you need strict typing and compiler validation, choose const or enum. Macros are better left for simple compile-time expressions and configuration flags.
Arithmetic operations
Arithmetic in C is built around the operators +, -, *, /, and %. Before evaluation, the compiler often converts operands to a common type. The GNU manual phrases it as convert their operands to the common type before operating on them
. Source: Common Type.
Semantics of the core operations:
a + b: adds the values after conversion to a common type.a - b: subtracts the right operand from the left.-a: unary negation.a * b: multiplication after type conversion.a / b: division. For integers, this is integer division.a % b: remainder after integer division.
The GNU manual says directly about division: The result is always rounded towards zero.
. Source: Division and Remainder.
Important constraints:
- Division by zero for integers is undefined behavior.
- Signed integer overflow is undefined behavior.
- For
unsigned, arithmetic is performed modulo2^N.
Formal rules
- Binary arithmetic operators first convert operands via the usual arithmetic conversions.
- For integer
/and%, the quotient is rounded toward zero and the remainder is related to the quotient by the standard division-with-remainder identity. - Division by zero is undefined behavior.
- Signed integer overflow in arithmetic expressions is undefined behavior.
Source: N3096 (C23 draft).
#include <stdio.h>
int main(void) {
int a = 17;
int b = 5;
int neg = -17;
double x = 17.0;
double y = 5.0;
printf("a + b = %d\n", a + b);
printf("a - b = %d\n", a - b);
printf("a * b = %d\n", a * b);
printf("a / b (int) = %d\n", a / b);
printf("a %% b = %d\n", a % b);
printf("-17 / 5 (int) = %d\n", neg / b);
printf("-17 %% 5 (int) = %d\n", neg % b);
printf("x / y (double) = %.2f\n", x / y);
printf("2 + 3 * 4 = %d\n", 2 + 3 * 4);
printf("(2 + 3) * 4 = %d\n", (2 + 3) * 4);
return 0;
}Expected output:
a + b = 22
a - b = 12
a * b = 85
a / b (int) = 3
a % b = 2
-17 / 5 (int) = -3
-17 % 5 (int) = -2
x / y (double) = 3.40
2 + 3 * 4 = 14
(2 + 3) * 4 = 20This example shows integer division, the sign of the remainder, and the effect of operator precedence.
Conditional operations
Conditional expressions in C fall into two groups: comparisons (==, !=, <, <=, >, >=) and logical operators (!, &&, ||).
The GNU manual summarizes logical results this way: The result of a logical expression is always 1 or 0.
Source: Logical Operators.
Detailed operator semantics:
a == b: true if the values are equal.a != b: true if the values differ.a < b,a <= b,a > b,a >= b: ordering relations over numbers.!x: returns 1 whenxis zero, otherwise 0.x && y: logical AND, where the right side is evaluated only if the left side is true.x || y: logical OR, where the right side is evaluated only if the left side is false.
Important edge cases:
- With
doubleandNaN, comparisons like==,<, and>are false, while!=is true. - Ordering comparisons of pointers are only valid within a single array or one-past that array.
Formal rules
- Relational and equality operators return the integer value 0 or 1.
&&and||evaluate the left operand first; the right operand is evaluated only if needed for the result.!returns 1 for zero and 0 for non-zero values.- For pointers, ordering comparisons are defined only in cases allowed by the standard.
Source: N3096 (C23 draft).
#include <math.h>
#include <stdio.h>
int side_effect(void) {
puts("side effect happened");
return 1;
}
int main(void) {
bool can_enter = true;
int age = 20;
int balance = 50;
double nan = NAN;
int arr[3] = {10, 20, 30};
int *p = &arr[0];
int *q = &arr[2];
printf("age >= 18 -> %d\n", age >= 18);
printf("age == 21 -> %d\n", age == 21);
printf("age != 21 -> %d\n", age != 21);
printf("age < 30 -> %d\n", age < 30);
printf("age <= 20 -> %d\n", age <= 20);
printf("age > 30 -> %d\n", age > 30);
printf("(age >= 18 && can_enter) -> %d\n", age >= 18 && can_enter);
printf("(balance > 100 || can_enter) -> %d\n", balance > 100 || can_enter);
printf("!(age < 18) -> %d\n", !(age < 18));
printf("nan == nan -> %d\n", nan == nan);
printf("nan != nan -> %d\n", nan != nan);
printf("p < q (same array) -> %d\n", p < q);
printf("0 && side_effect() -> %d\n", 0 && side_effect());
printf("1 || side_effect() -> %d\n", 1 || side_effect());
return 0;
}Expected output:
age >= 18 -> 1
age == 21 -> 0
age != 21 -> 1
age < 30 -> 1
age <= 20 -> 1
age > 30 -> 0
(age >= 18 && can_enter) -> 1
(balance > 100 || can_enter) -> 1
!(age < 18) -> 1
nan == nan -> 0
nan != nan -> 1
p < q (same array) -> 1
0 && side_effect() -> 0
1 || side_effect() -> 1In the last two lines, the function side_effect() is not called at all. That is exactly the short-circuit behavior of && and ||.
Bitwise operations
Bitwise operations work with the individual bits of an integer: &, |, ^, ~, <<, and >>. The GNU manual summarizes this simply: Bitwise operators operate on integers, treating each bit independently.
. Source: Bitwise Operations.
Semantics of the core operations:
~x: inverts every bit in the operand.x & y: a bit is 1 only if it is 1 in both operands.x | y: a bit is 1 if it is 1 in at least one operand.x ^ y: a bit is 1 if the bits differ.x << n: left shift bynbits.x >> n: right shift bynbits.
Important constraints:
- Integer promotions happen before evaluation.
- The shift count must be in the range
0 .. (width - 1); a negative shift or a shift by the type width or more is undefined behavior. - For
unsigned, shifts are usually more predictable; for negative signed values, right shift may be implementation-defined.
Formal rules
- Integer promotions are applied before bitwise operations.
- For shifts, the right operand must be non-negative and smaller than the bit width of the left operand.
- Shifting a signed value outside its range may produce undefined behavior.
- Right shift of a negative signed value may be implementation-defined.
Source: N3096 (C23 draft).
#include <stdio.h>
int main(void) {
unsigned int flags = 5; // 00000101
unsigned int mask = 4; // 00000100
unsigned int read_bit2 = (flags & mask) != 0;
unsigned int set_bit1 = flags | 2; // 00000111
unsigned int clear_bit0 = flags & ~1u; // 00000100
unsigned int toggle_bit2 = flags ^ 4; // 00000001
printf("flags & mask = %u\n", flags & mask);
printf("flags | mask = %u\n", flags | mask);
printf("flags ^ mask = %u\n", flags ^ mask);
printf("(unsigned char)~flags = %u\n", (unsigned int)(unsigned char)~flags);
printf("flags << 1 = %u\n", flags << 1);
printf("flags >> 1 = %u\n", flags >> 1);
printf("read bit2 = %u\n", read_bit2);
printf("set bit1 = %u\n", set_bit1);
printf("clear bit0 = %u\n", clear_bit0);
printf("toggle bit2 = %u\n", toggle_bit2);
return 0;
}Expected output:
flags & mask = 4
flags | mask = 5
flags ^ mask = 1
(unsigned char)~flags = 250
flags << 1 = 10
flags >> 1 = 2
read bit2 = 1
set bit1 = 7
clear bit0 = 4
toggle bit2 = 1Assignment operations
Compound assignments such as +=, -=, *=, /=, %=,&=, |=, ^=, <<=, and >>= make state updates shorter and more explicit.
Formal rules
- The left side of an assignment must be a modifiable lvalue.
- In a simple assignment, the right side is converted to the type of the left side.
- A compound assignment
E1 op= E2is equivalent toE1 = E1 op E2, except thatE1is evaluated only once. - An assignment expression has the value of the assigned result after the assignment is complete.
Source: N3096 (C23 draft).
#include <stdio.h>
int main(void) {
int n = 10;
n += 5;
n -= 3;
n *= 2;
n /= 4;
n %= 3;
printf("n = %d\n", n);
unsigned int bits = 1; // 0001
bits <<= 2;
bits |= 2;
bits &= 7;
bits ^= 1;
bits >>= 1;
printf("bits = %u\n", bits);
return 0;
}Expected output: n = 0, bits = 3. This style is especially convenient in loops and in state-machine logic.
Type conversions
C conversions can be implicit (automatic conversions) or explicit (casts). This is one of the most important topics because many silent mistakes start here.
The GNU manual states the base rule as follows: C converts values from one data type to another automatically
. Source: Type Conversions.
Practical order of implicit conversions in expressions:
- Value transformations: lvalue-to-rvalue, array-to-pointer, function-to-pointer.
- Integer promotions: types narrower than
intare promoted tointorunsigned int. - Usual arithmetic conversions: two operands are converted to a common type.
- Assignment or parameter conversions: a value is converted to the destination type.
Formal rules
- Expressions apply value transformations, then integer promotions, then the usual arithmetic conversions.
- Assignments and function arguments convert the value to the destination type.
T*andvoid*can be converted mutually; qualifiers cannot be dropped implicitly.- Some conversions have limits, and values outside the destination range may trigger undefined behavior.
Source: N3096 (C23 draft).
Things that are usually valid and common:
- Numeric conversions between integers and floating-point types, with possible precision loss.
- Conversion between
T*andvoid*. - Conversion of a null pointer (
nullptr) to any pointer type. - Implicit addition of qualifiers, for example
int*toconst int*.
Things that are dangerous or not allowed:
- You cannot implicitly drop the
constqualifier. - Converting
doubletointoutside the destination range is undefined behavior. - Converting an arbitrary integer to a pointer is implementation-defined and usually non-portable.
- Casting a pointer to an incompatible type and then dereferencing it may break aliasing or alignment rules.
#include <stdint.h>
#include <stdio.h>
#include <stddef.h>
int main(void) {
char c = 120;
short s = 1000;
int promoted = c + s; // char/short -> int
double mix = promoted + 0.25; // int -> double
int narrowed = (int)mix; // explicit narrowing
int a = 7;
int b = 2;
double wrong_division = a / b; // 3.0
double correct_division = (double)a / b; // 3.5
int truncated = (int)3.99; // 3
int value = 123;
void *vp = &value; // int* -> void*
int *back = (int *)vp; // void* -> int*
uintptr_t raw = (uintptr_t)back;
int *ptr = nullptr;
const int *pc = &value;
printf("promoted = %d\n", promoted);
printf("mix = %.2f\n", mix);
printf("narrowed = %d\n", narrowed);
printf("wrong_division = %.1f\n", wrong_division);
printf("correct_division = %.1f\n", correct_division);
printf("truncated = %d\n", truncated);
printf("*back = %d\n", *back);
printf("raw pointer as integer = %zu\n", (size_t)raw);
printf("ptr == nullptr -> %d\n", ptr == nullptr);
return 0;
}This example shows an entire chain of conversions: promotions, usual arithmetic conversions, explicit narrowing casts, and pointer conversions.
Conditional constructs
The main branching constructs are if / else if / else, switch-case, and the ternary operator ?:. Each is useful in its own context, from simple checks to branching by discrete state codes.
Formal rules
- In
if, the condition must have a scalar type. - In
switch, the controlling expression must have an integer type, andcaselabels must be integer constant expressions. - After conversion to the controlling type,
casevalues must not duplicate each other. - In
E1 ? E2 : E3, only one ofE2orE3is evaluated.
Source: N3096 (C23 draft).
if / else if / else
#include <stdio.h>
int main(void) {
int score = 82;
if (score >= 90) {
printf("grade A\n");
} else if (score >= 75) {
printf("grade B\n");
} else {
printf("grade C\n");
}
return 0;
}switch-case
#include <stdio.h>
int main(void) {
int day = 3;
switch (day) {
case 1:
printf("Monday\n");
break;
case 2:
printf("Tuesday\n");
break;
case 3:
printf("Wednesday\n");
break;
default:
printf("Unknown day\n");
break;
}
return 0;
}Ternary operator
#include <stdio.h>
int main(void) {
int number = 9;
const char *parity = (number % 2 == 0) ? "even" : "odd";
printf("%d is %s\n", number, parity);
return 0;
}If the branch is small and returns one expression, the ternary operator makes the code shorter. If the logic is complex, a normal if is often better for readability.
Loops
C has three basic loops: for, while, and do...while. In addition, break exits a loop early, while continue skips to the next iteration.
Formal rules
whilechecks the condition before each iteration.do...whilechecks the condition after the body, so the body runs at least once.for(init; cond; iter)follows the order init → cond → body → iter.breakexits the nearest loop orswitch;continuestarts the next iteration of the nearest loop.
Source: N3096 (C23 draft).
for
#include <stdio.h>
int main(void) {
int sum = 0;
for (int i = 1; i <= 5; i++) {
sum += i;
}
printf("sum = %d\n", sum);
return 0;
}while + break/continue
#include <stdio.h>
int main(void) {
int i = 0;
while (i < 10) {
i++;
if (i % 2 != 0) {
continue;
}
if (i > 6) {
break;
}
printf("%d ", i);
}
printf("\n");
return 0;
}Expected output: 2 4 6.
do...while
#include <stdio.h>
int main(void) {
int n = 0;
do {
printf("n = %d\n", n);
n++;
} while (n < 3);
return 0;
}The key feature of do...while is that the body runs at least once, because the condition is checked after the block executes.
Arrays and strings
The official GNU manual says it precisely about arrays: An array is a data object that holds a series of elements, all of the same data type.
. Source: GNU Arrays.
For strings, the official rule is that a string must be terminated with the null character
. Source: GNU Strings. This matters whenever you read, copy, or print strings.
#include <stdio.h>
int main(void) {
int numbers[] = {10, 20, 30, 40, 50};
size_t n = sizeof(numbers) / sizeof(numbers[0]);
int total = 0;
for (size_t i = 0; i < n; i++) {
total += numbers[i];
}
char name[] = "Alice"; // {'A','l','i','c','e','\0'}
printf("n = %zu\n", n);
printf("total = %d\n", total);
printf("name = %s\n", name);
printf("name length by sizeof = %zu\n", sizeof(name) - 1);
return 0;
}Expected output:
n = 5
total = 150
name = Alice
name length by sizeof = 5Important: arrays do not store their length separately, so it is usually computed as sizeof(arr) / sizeof(arr[0]) in the same scope where the object is still an array rather than a pointer.
Console input: the scanf function
The official docs give a key rule for scanf: The return value is normally the number of successful assignments.
. Source: Formatted Input Functions.
That rule should always be used in practice: check the return value of scanf, or the program may continue with uninitialized data after a partially failed input operation.
#include <stdio.h>
int main(void) {
int age;
double height;
char name[32];
size_t copies;
printf("Enter age, height, name, copies: ");
int read = scanf("%d %lf %31s %zu", &age, &height, name, &copies);
if (read != 4) {
printf("Input error: expected 4 values, got %d\n", read);
return 1;
}
printf("age = %d\n", age);
printf("height = %.2f\n", height);
printf("name = %s\n", name);
printf("copies = %zu\n", copies);
return 0;
}Why %31s: it limits the length for the buffer name[32]. Why %zu: the variable copies has type size_t, so the format must match that type exactly.
Example input/output session:
Enter age, height, name, copies: 25 181.4 Alex 3
age = 25
height = 181.40
name = Alex
copies = 3