C Language Constructs

In this post I explain the core constructs of the C language: variables, types, operators, conditions, loops, arrays, strings, and console input/output.
TopicsC

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 = B

In 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) = 8

C23 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 = 1

You 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 -> 1

This 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 = 150

Console 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, and 0 uses zero padding.
  • width: the minimum field width. It can be a literal number or * when the width comes from an int argument.
  • .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 are hh, h, l,ll, j, z, t, and L.
  • conversion: the representation kind. Common conversions are d/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 = 3

Constants

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 case labels.
  • #define works 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 = 2

If 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 modulo 2^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 = 20

This 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 when x is 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 double and NaN, 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() -> 1

In 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 by n bits.
  • x >> n: right shift by n bits.

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 = 1

Assignment 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= E2 is equivalent to E1 = E1 op E2, except that E1 is 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:

  1. Value transformations: lvalue-to-rvalue, array-to-pointer, function-to-pointer.
  2. Integer promotions: types narrower than int are promoted to int or unsigned int.
  3. Usual arithmetic conversions: two operands are converted to a common type.
  4. 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* and void* 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* and void*.
  • Conversion of a null pointer (nullptr) to any pointer type.
  • Implicit addition of qualifiers, for example int* to const int*.

Things that are dangerous or not allowed:

  • You cannot implicitly drop the const qualifier.
  • Converting double to int outside 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, and case labels must be integer constant expressions.
  • After conversion to the controlling type, case values must not duplicate each other.
  • In E1 ? E2 : E3, only one of E2 or E3 is 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
  • while checks the condition before each iteration.
  • do...while checks the condition after the body, so the body runs at least once.
  • for(init; cond; iter) follows the order init → cond → body → iter.
  • break exits the nearest loop or switch; continue starts 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 = 5

Important: 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