Синтаксическое пересечение C и C++


Мы наконец-то начинаем говорить про C++. Начать, разумеется, надо с базовых вещей, которые появились ещё в C. И вообще текущая тема может быть по праву названа как введением в C, так и введением в C++.

Однако C и C++ — это разные языки:

  1. Во-первых, их разрабатывают разные люди с разными целями.
  2. Во-вторых, они имеют разные компиляторы, несмотря на то, что обычно компании, имеющие компиляторы C++ также имеют компилятор C. Эти компиляторы имеют общий код, но они всё же не одинаковы — этот самый общий код также используется и для Go, и для D, и для Ada... Исключением из этого правила является Clang, где просто if'ами различаются C и C++. Правда, там ещё и Objective-C и нечто ещё...
  3. В-третьих, стили программирования на C и C++ кардинально отличаются, если вы пишете на них одинаково, вы дурачок.
  4. В-четвёртых, C не является подмножеством C++, случайная программа на C вообще не факт что будет корректна в C++. Правда, обычно придётся менять её не очень сильно. Примером такого отличия является код вида a ? b : c = 42. В C — это (a ? b : c) = 42, а в C++ — a ? b : (c = 42).

Так вот, у нас всё будет обсуждаться в терминах C++: в местах отличий мы не будем обсуждать оба языка.

Поправка по курсу. Считается, что мы знаем, что такое переменная, как вообще всё живётся, а обсуждать будет то, о чём либо редко говорят, либо о том, что специфично для C/C++.

Типы данных.

Целочисленные.

Целочисленных типов в языке C++ девять:

  • char.
  • unsigned char.
  • signed char.
  • [signed] short [int].
  • unsigned short [int].
  • [signed] int или просто signed.
  • unsigned [int].
  • [signed] long [int].
  • unsigned long [int].
  • [signed] long long [int].
  • unsigned long long [int].

Квадратными скобками помечены слова, которые можно просто опустить в объявлении типа.

Стандарт не приписывает конкретных размеров типа, гарантирует только, что не меньше некоторого размера. Размеры типов прописаны в ABI архитектуры.

РазмерСтандарт32 bitwin64linux64
char$1$ байт888
short$\leqslant16$ бит161616
int$\leqslant16$ бит323232
long$\leqslant32$ бит323264
long long$\leqslant64$ бит646464

Надо понимать, что C++ поддерживает системы, в которых байт не равен 8 битам. В частности, на cppreference можно найти такую строку: «Note: this allows the extreme case in which bytes are sized 64 bits, all types (including char) are 64 bits wide, and sizeof returns 1 for every type.»

Символьные типы.

Как следует из списка выше, char, unsigned char и signed char — это три разных типа.

Как проверить, одинаковые ли типы? Например, перегрузить функцию:

void foo(int) {}
void foo(signed int) {} // Не скомпилируется, так как две функции с одинаковой сигнатурой.
void foo(char) {}
void foo(signed char) {}
void foo(unsigned char) {} // Скомпилируется.

Типы с фиксированным размером.

Несмотря на то, что стандарт не гарантирует ничего про размеры типов данных, существуют типы с фиксированным размером:

#inlcude <cstdint>
int8_t			uint8_t
int16_t			uint16_t
int32_t			uint32_t
int64_t			uint64_t

Следует использовать их, если хотим тип гарантированного размера. Эти типы существуют в том и только в том случае, если реализация имеет тип соответствующего размера. В частности, а патологическом случае указанном выше (где байт имеет 64 бита), из типов фиксированного размера существуют только int64_t и uint64_t.

Прочие полезные typedef'ы.

Нужно вам перебрать все значения в массиве:

for (/*???*/ i = 0; i < N; i++)
	arr[i] = 42;

Какого типа должно быть i?

size_t - тип, размер которого необходим и достаточен, для хранения размера массива. Очень рекомендуется для индексов и размеров структур в памяти использовать size_t. Если вы будете брать тип фиксированной длины больше, чем size_t — будет немного медленнее, а если меньше — то может не хватить для адресного пространства. К тому же компилятор может немного хуже оптимизировать код, если вы используете размер меньше size_t. size_t беззнаковый, а его размер обычно равен разрядности вашей системы. Также возвращаемое значение sizeof(...) — это size_t.

У size_t есть знаковый друг — ptrdiff_t — результат разности двух указателей.

Как выбирать целочисленный тип.

  • Если данные приходят из существующей функции или уходят в неё, то используем тот же тип, что там.
  • Если используется как размер/индекс или сдвиг в контейнере — size_t и ptrdiff_t.
  • Если знаем, оцениваем размер, используем тип фиксированного размера.

Типы с плавающей точкой.

ТипРазмер (обычно)
float32
double64
long double64/80/128

Разделение на мантиссу и экспоненту фиксировано в стандарте IEEE-754

Подробнее о них (про денормализованные числа, NaN, $\pm\infty$, отрицательный 0 и подобное) можно почитать на викиконспектах или узнать в курсе архитектуры ЭВМ.

Стоит заметить, что из-за особенностей чисел с плавающей точкой (обычно из-за NaN'ов, бесконечностей и нулей разного знака) операции вида 0 * a и a - a не могут быть заменены при компиляции на 0 (о части из них также можно почитать в приложенной ссылке на конспект по АрхЭВМ). Но можно прописать флаги компилятора, игнорирующие NaN и $\infty$ и тогда арифметические действия будут быстрее (но не будут соответствовать стандарту IEEE-754). Одним из примеров невозможности оптимизации является if (a == a), что вернёт false, если a является NaN'ом:

David Golberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic

Intel overstates FPU accuracy

Перечисляемый тип.

Самым простым составным типом является перечисляемый тип. В C++ рекомендуется использовать только строгий их вариант — enum class. Это кто? Это перечисление набора вариантов. Это лучше, чем набор констант, потому что enum class:

  • Безопаснее (в него сложно присвоить то, чего в нём нет).
  • Понятнее (если в функцию передаётся три разных int'а, то вы легко их перепутаете, а перепутать три разных перечисляемых типа вам не дадут).
  • Сразу видно, какие константы связаны друг с другом.

Изнутри enum class — это просто целое число, причём тип этого числа можно явно указать при помощи такой конструкции:

enum class color : uint16_t
{
	RED,
	GREEN,
	BLUE
};

По умолчанию внутри enum class лежит int. Тот тип, который лежит внутри, называется underlying type. Но арифметика со строгими перечисляемыми типами не работает.

Есть ещё обычный enum, без слова class. Это тип-перечисление, который пришёл из Си. Но им лучше не пользоваться, потому что:

  • У enum не прописано, какой тип внутри (underlying type). Причём не прописано именно в стандарте. То есть это может отличаться от компилятора к компилятору.
  • Они неявно конвертируются в int. Мы не хотим неявных конверсий.
  • И им ещё можно не указывать спецификатор: можно просто red вместо color::red. Получается, они засоряют пространство имен. И, например, такой код не компилируется, потому что имена c конфликтуют (если заменить на enum class, то будет работать):
enum programming_languages
{
	c, cpp
};

enum letters
{
	a, b, c
};

Структуры и указатели:

Структура

struct point {
	float x;
	float y;
	float z;
};

// Обращение к полям:
void f (point p) {
	p.x = 5;
}

Структура — это способ сгруппировать набор данных в одну сущность. Структурами очень рекомендуется пользоваться: не надо таскать в разные места несгруппированные данные. Иначе ваш код раздуется до невероятных размеров и вообще обретёт форму спагетти. А ещё вы получите больший шанс ошибиться.

Данные структуры хранятся подряд (с точностью до выравнивания). Какое такое выравнивание? Сразу виден человек, не читавший конспект по ассемблеру. Процессоры либо не умеют, либо плохо читают $N$ байт, адрес начала которых не делится на $N$ (это называется невыравненное обращение к памяти/unaligned access). Поэтому компиляторы стараются располагать структуры так, чтобы данные в них были выравнены. Подробнее о невыравненном обращении можно почитать здесь.

Пример:

struct mytype1
{
	char a;
	int b;
};

struct mytype2
{
	int b;
	char a;
};

Как несложно догадаться, первая структура занимает 8 байт, а не 5, потому что после a добавляются 3 байта. Более интересно, что вторая структура тоже занимает 8 байт, потому что когда вы положите такую структуру в массив, придётся после неё вставлять 3 байта.

Чтобы узнать, сколько места занимает структура (когда она лежит в массиве), есть оператор sizeof. Использование: sizeof(mytype1), возвращает в нашем случае 8.

А ещё есть оператор alignof. Он возвращает выравнивание структуры. Выравнивание структуры — это число, на которое должен делиться тот адрес, по которому мы размещаем структуру. Использование: alignof(mytype1), возвращает в нашем случае 4.

Кстати, sizeof и alignof можно применять не только к типам, но и к значениям. Вызов этих функций от значения эквивалентен вызову от типа этого значения.

Объединение.

У нас есть тип, которых хранит первое, И второе, И третье. А что, если мы хотим хранить строго одно из нескольких значений? Специально для этого есть union, который этим и занимается. Пока struct хранит следующее поле по смещению относительно предыдущего, у union'а всё хранится по одному смещению.

Важно: никакой информации о том, что хранится в данный момент, union не знает. Если вас это устраивает, вас это устраивает, а иначе вам нужно связать union с enum class в одну структуру (обращение не в ту альтернативу union'а — undefined behaviour). И называется эта структура std::variant:)

Указатели и массивы.

Указатели.

Указатель — «номер ячейки памяти» (важно указывать, какой тип в это ячейке, эта информация используется на уровне компилятора). Все указатели имеют одинаковый размер - битность системы.

mytype *p1; // Объявление указателя на тип `mytype`.

int a;
int *p2 = &a; // `&` — взятие адреса переменной.
*p2 = 42;     // `*` — разыменования указателя (взять значение того, что в этой ячейке).

point *p;
// Вместо:
(*p).x = 5;
// Можно написать:
p->x = 5;
// Второе — просто сокращение для первого.

C++-style массивы.

#include <array>

std::array<int, 20> arr; // Массив из 20 целых чисел.
arr[2] = 123;            // Обращение к элементу массива (0-based).
int *p = arr.data();     // Указатель на первый элемент массива (может быть использовано для арифметики указателей).

C-style массивы.

int a[10]; // Массив из 10 целых чисел.
a[1] = 42; // Всё такое же 0-based обращение к элементу.

У массивов из C (далее встроенные массивы) по сравнению с std::array есть существенные недостатки:

  • Встроенные массивы неявно конвертируются в указатели (что вызывает путаницу с тем, являются ли указатели и массивы одним и тем же или нет).
  • Встроенные массивы нельзя копировать (поэтому нельзя их в функцию передавать, например).
  • А если вы напишете встроенный массив в параметре функции, то он тоже неявно конвертируется в указатель:
void f(int a[10]){}
// компилируется в 
void f(int* a){}

Арифметика указателей.

long long *p;
int n; // Любой целочисленный тип.

long long *q = +p;      // `+p` — то же, что `p`. По полезности как писать `a = +1` вместо `a = 1`.
p++;                    // Перейти к следующему объекту в памяти.
p--;                    // Перейти к предыдущему.
p += n;                 // Добавить к указателю `n`.
p -= n;                 // Вычесть из указателя `n`.
ptrdiff_t diff = p - q; // Разность указателей на одинаковый тип — количество элементов между ними.

p[10] = -5; // `p[10]` равносильно `*(p + 10)`.
10[p] = -5; // Равносильно `*(10 + p)`. Так можно, но Безымянного Бога ради не делайте так.

void*.

Есть особый указатель, пришедший из C — void*. Любой указатель неявно приводится в void*, а void* можно явно (в C — неявно) привести куда угодно. И это только ваша ответственность следить за тем, чтобы это приведение было корректно.

Используется void* во всяких интерфейсах из C, где неизвестен тип объекта (как то malloc или qsort). В C++ он обычно не нужен.

Сочетание указателей и массивов.

Ещё с массивами из C есть вопрос: int* a[10] — это кто такой: массив указателей или указатель на массив? Первое. Второе — это int (*a)[10]. В общем случае суффиксные деклараторы имеют больший приоритет, чем префиксные (т.е. это в первую очередь массив чего-то, а во вторую «что-то — это указатели»). Ровно также работает использование: если вы пишете выражение x = *a[1], то у вас сначала будет обращение к первому элементу, а потом его разыменовывание.

Но вообще люди обычно не пишут все эти скобки, а пишут что-то такое:

typedef int type[10];
type* a; // int (*a)[10];

Вопрос на засыпку: как хотим завести себе typedef, который будет являться типом переменной

int ***(***a[10][20][30])[40][50][60];

Да элементарно:

typedef int ***(***type[10][20][30])[40][50][60];

То есть никакой разницы, переменную вы объявляете или typedef делаете.

Указатели на функции.

В ассемблере вы могли сделать что-то такое:

mov RBX, func
; ...
call RBX

В C и C++, разумеется, так тоже можно:

void func(int) {}
void main()
{
	void (*a)(int) = &func;
	(*p)(42);
}

Это можно использовать для полиморфного поведения. Ещё сто́ит сказать, что указатель на функцию можно вызвать. И более того, функции неявно преобразуются в указатели на себя. Поэтому точно такой же код можно написать так:

void func(int) {}
void main()
{
	void (*a)(int) = func;
	p(42);
}

Указатели на функции подчиняются тем же правилам приоритета, что массивы и обычные указатели. При этом декларатор указателя на функцию считается суффиксным.

Следующий ужас, который мы можем увидеть — функция, возвращающая указатель на функцию. Это выглядит так:

void (*get_function())(int)

То есть это как объявление указателя на функцию, но с круглыми скобками после имени (это же не сам указатель, а функция, возвращающая его). То есть возвращаемое значение пишется не слева от функции, а вокруг. Хотя на практике с таким не встречаются, а делают typedef.

Мем про switch.

void f(int a) {
	switch (a) {
	case 1:
		printf("1\n");
		if (false)
	case 2:
		printf("2\n");
		if (false)
	case 3:
		printf("3\n");
		if (false)
	default:
		printf("x\n");
	}
}

Код выводит 1, 2 и 3 для соответствующих значений и x иначе. И непонятно, почему. А потому что case — это метки. И switch делает goto по ним. И если не опускать фигурные скобки в данной записи, то получится что-то такое:

void f(int a) {
	switch (a) {
	case 1:
		printf("1\n");
		if (false) {
		case 2:
			printf("2\n");
		}
		if (false) {
		case 3:
			printf("3\n");
		}
		if (false) {
		default:
			printf("x\n");
		}
	}
}

И теперь в целом понятно, что происходит, мы прыгаем внутрь if (false). Так писать ни в коем случае не надо, но с точки зрения языка возможно.

lvalue, rvalue (until C++11).

Понятно, что мы не можем написать что-то типа 2 + 2 = 7, хотя и слева, и справа — int. Но всё же, почему конкретно, как это в языке работает? А так что в языке есть две категории значений:

  • lvalue — то, что может стоять слева от оператора присваивания.
  • rvalue — то, что не может. Обычно временные объекты.

Ещё обычно у lvalue можно взять адрес, а у rvalue — нельзя.

&a;          // ok.
&5;          // `5` — rvalue.
&&a;         // `&a` — rvalue.

++a;         // Увеличивает и **возвращает по ссылке**.
a++;         // **Возвращает копию**, а потом увеличивает.
a++++;       // `a++` - rvalue.
++++a;       // В C++ ok, в C — нет.
++a++;       // `a++` — rvalue (суффиксный оператор имеет приоритет).
+++a;        // `+a` — rvalue (лексер работает жадно, воспринимая это как `++(+a)`).

a = 4;       // Присваивание возвращает **левый аргумент по ссылке**.
(a = 5) = 6; // ok.
a = b = c;   // ok, `a = (b = c)`.

Детали работы с числами.

Суффиксы констант.

Какой тип имеет 42? int. А если мы хотим другой?

ТипПример
int42
unsigned42U
long42L
unsigned long42UL
long long42LL
unsigned long long42ULL
float42.0f
double42.0
long double42.0L

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

Приведение типов.

Что будет, если складывать числа разного типа? А вот что. Упорядочим числа в такой ряд

  1. int.
  2. unsigned.
  3. long.
  4. unsigned long.
  5. long long.
  6. unsigned long long.

Тогда из двух типов выбирается тот, кто позже в этом списке, оба аргумента приводятся к нему и результат будет того же типа.

Нюансы проявляются в том, что будет, если складывать два char'а, например. Будет int. Потому что все арифметические операции с числами меньше int'а выполняются в типе int. Более того, вам даже операции делать не надо, при вызове функции это преобразование также происходит.

Поэтому, кстати, если вы хотите принимать в функцию все типы (чтобы они сохраняли численное значение), то вам хватит int и больше. Реализовать только long long и unsigned long long вы не можете, потому что long, например, не будет знать, куда ему конвертиться. А почему char и short будут? А потому что в языке есть 3 типа конверсий (exact match, promotion и convertion), каждый следующий хуже всех предыдущих, и если у вас есть два одинаково хороших варианта, то ошибка компиляции. Так вот конверсия из short'а в int — promotion, а long в long long или unsigned long long convertion'ы. Про всё это подробно можно почитать тут.

На дробных числах promotion также есть (из float в double), но все операции с float'ами во float'ах и осуществляются. Если вы совершаете операцию, один аргумент которой — целое число, а другой — число с плавающей точкой, то целое приводится к вещественному.