Конструкции языка C

В этом посте разбираю базовые конструкции языка C: переменные, типы, операторы, условия, циклы, массивы, строки и ввод/вывод.

Конструкции языка C в стандарте C23

Этот материал ориентирован на C23. Для проверки примеров используйте режим стандарта: -std=c23. Официальные ссылки по режимам языка и поддержке компилятора: GCC Standards и GCC C status.

На странице GCC C status есть важная формулировка: C23 mode is the default since GCC 15. Это означает, что в новых версиях GCC C23 уже базовый режим, но при написании обучающих примеров всё равно полезно явно указывать -std=c23, чтобы поведение было предсказуемым на разных машинах.

Структура программы на Си

Минимальная программа на C состоит из подключений заголовков, объявлений/определений функций и точки входа main. Подробно про структуру программы в официальном GNU-руководстве: 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> подставляет объявления стандартного ввода/вывода; функция square сначала объявлена и определена, затем вызывается из main; return 0; возвращает код завершения в ОС. Такой порядок помогает компилятору корректно проверять типы и сигнатуры вызовов.

Для C23 особенно важно использовать полноценные прототипы функций и избегать старых неявных стилей. Это снижает риск ошибок при преобразованиях аргументов и на этапе линковки.

Переменные

Переменная в C имеет тип, имя, область видимости и время жизни. Официальный вводный раздел: GNU Variables. В практическом коде переменные лучше инициализировать сразу, иначе легко получить неопределенное поведение.

#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;
}

Ожидаемый вывод:

age = 21
temperature = 36.6
grade = A
updated -> age = 22, temperature = 37.0, grade = B

В этом примере все переменные явно инициализируются. Если убрать инициализацию локальной переменной и сразу прочитать ее значение, программа формально становится некорректной с точки зрения стандарта.

Типы данных

Базовые типы: char, int, float, double плюс модификаторы short, long, unsigned. Размеры типов платформо-зависимы, поэтому в переносимом коде их проверяют через 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;
}

Пример вывода:

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

В C23 логический тип можно использовать напрямую, без прежней зависимости от <stdbool.h>. Это делает условия в коде читаемее и ближе к естественной логике предметной области.

#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;
}

Ожидаемый вывод:

is_admin = 1
has_2fa = 0
is_admin && !has_2fa = 1

Здесь видно, что логические выражения по-прежнему печатаются как 0/1, но семантика кода становится прозрачнее: условие описывает смысл напрямую, а не через целочисленные суррогаты.

C23 nullptr и nullptr_t

C23 вводит nullptr и nullptr_t. Это снимает старую неоднозначность, когда нулевой указатель часто записывали как 0 или NULL, что смешивало целочисленные и указательные контексты.

#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;
}

Ожидаемый вывод:

p == nullptr -> 1
np == nullptr -> 1

Эта запись полезна в ревью и сопровождении: по самому коду видно, что автор имеет в виду именно нулевой указатель, а не число.

size_t: размеры и индексы

Для размеров и индексов в C применяется size_t. В официальной документации GNU это прямо сформулировано как always an unsigned integer type для результата sizeof: см. Type Size.

Практическое следствие: если вы считаете количество элементов, длину буфера, размер блока памяти или индексируете массив, size_t, а не 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;
}

Ожидаемый вывод:

n = 5
sum = 150

Консольный вывод. Функция printf

Форматная строка printf состоит из обычных символов и спецификаторов конверсии. В GNU libc это сформулировано так: Characters in the template string ... are printed as-is, а спецификаторы задают преобразование аргументов. Источник: Output Conversion Syntax.

В man-page синтаксис указан явно: %[argument$][flags][width][.precision][length modifier]conversion. Это самая практичная схема для чтения любого сложного формата: printf(3).

Разбор по частям:

  • argument$: позиционная нумерация аргументов (например, %2$d). Полезно в локализации и форматах, где один и тот же аргумент используется несколько раз.
  • flags: модификаторы поведения. `-` — выравнивание влево, `+` — всегда печатать знак для знаковых чисел, пробел — печатать пробел вместо `+` для положительных, `#` — альтернативная форма (`0x`, `0`, обязательная точка у некоторых float-форматов), `0` — заполнение нулями. В POSIX/glibc также встречаются locale-флаги ' и I.
  • width: минимальная ширина поля. Можно числом (`%10d`) или через `*` (`%*d`, ширина берется из аргумента типа `int`). Отрицательная ширина эквивалентна флагу `-`.
  • .precision: точность. Для целых это минимум цифр, для float — число цифр после точки (`f/e/E`) или число значащих цифр (`g/G`), для строк (`%s`) — максимум символов.
  • length modifier: размер аргумента. Стандартные: `hh`, `h`, `l`, `ll`, `j`, `z`, `t`, `L`. Например, `%zu` для `size_t`, `%jd` для `intmax_t`, `%lld` для `long long`, `%Lf` для `long double`.
  • conversion: вид представления значения. Частые: `d/i`, `u`, `o`, `x/X`, `f/F`, `e/E`, `g/G`, `a/A`, `c`, `s`, `p`, `n`, `%`.

По документации glibc у %n есть отдельная семантика: он stores the number of characters printed so far и ничего не печатает. Источник: Other Output Conversions.

Критически важно подбирать формат под точный тип аргумента. Ошибка в спецификаторе при varargs-вызове может привести к неопределенному поведению.

#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;
}

Ожидаемый вывод:

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

Константы

В C обычно используют три формы: const для типизированных констант времени выполнения, #define для макроподстановки на этапе препроцессора и enum для именованных целочисленных значений. Это разные инструменты, и у каждого своя зона ответственности.

Формальные правила
  • const-квалифицированный объект не является modifiable lvalue: менять его через это lvalue нельзя.
  • Перечислители (enum) являются целочисленными константными выражениями и могут использоваться, например, в case-метках.
  • #define работает на этапе препроцессора и не создает типизированный объект языка.
  • В контекстах, где требуется constant expression, значение должно удовлетворять правилам constant expressions стандарта.

Источник: 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;
}

Ожидаемый вывод:

pi = 3.14159
MAX_USERS = 100
TUESDAY = 2

Если вам нужна строгая типизация и проверка компилятором, выбирайте const или enum. Макросы лучше оставлять для простых compile-time выражений и конфигурационных флагов.

Арифметические операции

Арифметика в C строится вокруг операторов +, -, *, /, %. Перед вычислением компилятор часто приводит типы операндов к общему типу. В GNU manual это сформулировано так: convert their operands to the common type before operating on them. Источник: Common Type.

Семантика каждой операции:

  • a + b: складывает значения после приведения к общему типу.
  • a - b: вычитает правый операнд из левого.
  • -a: унарное отрицание значения.
  • a * b: умножение после приведения типов.
  • a / b: деление. Для целых - целочисленное деление.
  • a % b: остаток от целочисленного деления.

Про деление в GNU manual есть прямая формулировка: The result is always rounded towards zero.. Источник: Division and Remainder.

Важные ограничения:

  • Деление на ноль для целых - неопределенное поведение.
  • Переполнение знаковых целых - неопределенное поведение.
  • Для unsigned арифметика выполняется по модулю 2^N.
Формальные правила
  • Бинарные арифметические операторы сначала приводят операнды через usual arithmetic conversions.
  • Для целочисленных / и % частное округляется к нулю; остаток связан с частным стандартным соотношением деления с остатком.
  • Деление на ноль - неопределенное поведение.
  • Переполнение signed integer в арифметических выражениях - неопределенное поведение.

Источник: 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;
}

Ожидаемый вывод:

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

Этот пример показывает целочисленное деление, знак остатка и влияние приоритета операций.

Условные операции

Условные выражения в C делятся на две группы: сравнения (==, !=, <, <=, >, >=) и логические операторы (!, &&, ||).

GNU manual формулирует результат логических выражений так: The result of a logical expression is always 1 or 0. Источник: Logical Operators.

Подробная семантика операторов:

  • a == b: истина, если значения равны.
  • a != b: истина, если значения различны.
  • a < b, a <= b, a > b, a >= b: отношения порядка над числами.
  • !x: возвращает 1, если x равно нулю, иначе 0.
  • x && y: логическое И, правая часть вычисляется только если левая истинна.
  • x || y: логическое ИЛИ, правая часть вычисляется только если левая ложна.

Важные граничные случаи:

  • Для double с NaN: сравнения ==, <, > ложны, а != истинно.
  • Порядковые сравнения указателей корректны только в пределах одного массива (или one-past).
Формальные правила
  • Реляционные и equality-операторы возвращают целое значение 0 или 1.
  • && и || вычисляют левый операнд первым; правый вычисляется только если это нужно для результата (short-circuit).
  • ! возвращает 1 для нуля и 0 для ненулевого значения.
  • Для указателей порядковые сравнения определены только в допустимых стандартом случаях (например, внутри одного массива).

Источник: 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;
}

Ожидаемый вывод:

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

В последних двух строках функция side_effect() не вызовется ни разу: это и есть short-circuit семантика && и ||.

Поразрядные операции

Поразрядные операции работают с битами целого числа: &, |, ^, ~, <<, >>. GNU manual формулирует это кратко: Bitwise operators operate on integers, treating each bit independently.. Источник: Bitwise Operations.

Семантика каждой операции:

  • ~x: инвертирует каждый бит операнда.
  • x & y: бит равен 1 только если он 1 в обоих операндах.
  • x | y: бит равен 1, если он 1 хотя бы в одном операнде.
  • x ^ y: бит равен 1, если биты различны.
  • x << n: сдвиг влево на n битов.
  • x >> n: сдвиг вправо на n битов.

Важные ограничения:

  • Перед вычислением применяются integer promotions.
  • Количество сдвига должно быть в диапазоне 0 .. (width-1); отрицательный сдвиг или сдвиг на ширину типа и больше - неопределенное поведение.
  • Для unsigned сдвиги обычно предсказуемее; для отрицательных signed-значений правый сдвиг может быть implementation-defined.
Формальные правила
  • Перед поразрядными операциями применяются integer promotions.
  • Для сдвигов правый операнд должен быть неотрицательным и меньше ширины левого операнда в битах.
  • Сдвиг signed-значения с выходом за диапазон может приводить к неопределенному поведению.
  • Правый сдвиг отрицательных signed-значений может быть implementation-defined.

Источник: 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;
}

Ожидаемый вывод:

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

Операции присваивания

Составные присваивания +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= сокращают запись и делают шаги обновления состояния явными.

Формальные правила
  • Левая часть присваивания должна быть modifiable lvalue.
  • При простом присваивании правая часть приводится к типу левой.
  • Составное присваивание E1 op= E2 эквивалентно E1 = E1 op E2 с тем отличием, что E1 вычисляется только один раз.
  • Выражение присваивания имеет значение присвоенного результата после выполнения присваивания.

Источник: 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;
}

Ожидаемый вывод: n = 0, bits = 3. Такой стиль особенно удобен в циклах и state-machine логике.

Преобразование типов

В C преобразования бывают неявные (automatic conversions) и явные (cast). Это одна из самых важных тем, потому что именно тут чаще всего появляются «тихие» ошибки.

В GNU manual базовое правило сформулировано так: C converts values from one data type to another automatically. Источник: Type Conversions.

Практический порядок неявных преобразований в выражениях:

  1. Value transformations: lvalue-to-rvalue, array-to-pointer, function-to-pointer.
  2. Integer promotions: типы меньше int (например, char, short) обычно поднимаются до int или unsigned int.
  3. Usual arithmetic conversions: два операнда приводятся к общему типу.
  4. Assignment/parameter conversions: при присваивании и передаче аргумента значение приводится к типу приемника.
Формальные правила
  • В выражениях применяются value transformations, затем integer promotions, затем usual arithmetic conversions.
  • При присваивании и передаче аргумента значение приводится к типу приемника.
  • T* и void* преобразуются взаимно; квалификаторы типа нельзя неявно отбрасывать.
  • Некоторые преобразования имеют ограничения: например, значение вне диапазона целевого signed-целого при cast из floating может дать неопределенное поведение.

Источник: N3096 (C23 draft).

Что обычно можно и корректно делать:

  • Числовые преобразования между целыми и вещественными типами (с учетом возможной потери точности).
  • Преобразование T* в void* и обратно.
  • Преобразование нулевого указателя (nullptr) к любому типу указателя.
  • Неявное добавление квалификаторов, например int* в const int*.

Что нельзя или опасно:

  • Нельзя неявно отбросить квалификатор const (например, const int* в int*).
  • Преобразование double в int при значении вне диапазона целевого типа ведет к неопределенному поведению.
  • Преобразование произвольного целого в указатель implementation-defined и часто непереносимо.
  • Приведение указателя к несовместимому типу и последующее разыменование может нарушить aliasing/alignment правила.
#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;       // явное сужение

  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; // int* -> const int* (разрешено)
  // int *bad = pc;       // нельзя: отброс const без явного приведения

  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;
}

Этот пример показывает сразу цепочку преобразований: promotions, usual arithmetic conversions, явные сужающие cast и работу с указателями.

Условные конструкции

Основные конструкции управления ветвлением: if / else if / else, switch-case и тернарный оператор ?:. Каждая нужна в своем контексте: от простых проверок до ветвления по дискретным кодам состояния.

Формальные правила
  • У if условие должно иметь scalar type.
  • В switch управляющее выражение должно иметь integer type; case-метки должны быть целочисленными константными выражениями.
  • После преобразования к типу управляющего выражения значения case не должны дублироваться.
  • В E1 ? E2 : E3 вычисляется только одна из ветвей E2/E3, в зависимости от результата E1.

Источник: 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;
}

Тернарный оператор

#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 ради читаемости.

Циклы

В C есть три базовых цикла: for, while, do...while. Дополнительно break завершает цикл досрочно, а continue пропускает текущую итерацию.

Формальные правила
  • while проверяет условие перед каждой итерацией.
  • do...while проверяет условие после тела, поэтому тело выполняется минимум один раз.
  • В for(init; cond; iter) этапы выполняются в порядке init → cond → body → iter.
  • break завершает ближайший цикл/ switch, continue передает управление на следующую итерацию ближайшего цикла.

Источник: 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;
}

Ожидаемый вывод: 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;
}

Ключевая особенность do...while: тело выполняется минимум один раз, потому что условие проверяется после выполнения блока.

Массивы и строки

Про массивы в официальном руководстве есть точная формулировка: An array is a data object that holds a series of elements, all of the same data type. Источник: GNU Arrays.

Для строк официальное правило: строка должна быть terminated with the null character. Источник: GNU 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;
}

Ожидаемый вывод:

n = 5
total = 150
name = Alice
name length by sizeof = 5

Важно: для массивов не хранится отдельная длина, поэтому ее обычно вычисляют формулой sizeof(arr) / sizeof(arr[0]) в той же области, где массив доступен как массив, а не как указатель.

Ввод в консоли. Функция scanf

Для scanf в официальной документации есть ключевая фраза: The return value is normally the number of successful assignments. Источник: Formatted Input Functions.

Это правило нужно использовать всегда: проверяйте возвращаемое значение scanf, иначе программа может продолжить работу с неинициализированными данными после частично неудачного ввода.

#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;
}

Почему %31s: это ограничитель длины для буфера name[32]. Почему %zu: переменная copies имеет тип size_t, и формат должен точно соответствовать типу.

Пример сессии ввода/вывода:

Enter age, height, name, copies: 25 181.4 Alex 3
age = 25
height = 181.40
name = Alex
copies = 3