Перейти к содержимому

Переменные и типы

  • Переменная как абстракция для ячейки памяти
  • Относительное расположение памяти переменных в оперативной памяти
  • Определение и объявление переменных
  • Операции с переменными (считать, вписать)
  • Неинициализированные переменные, мусорные данные
  • UB (undefined behavior)
  • Синтаксис инициализации
  • Значение
  • Выражение, процесс вычисления выражения
  • Типы данных: знак (минус), размер
  • Преобразования между числовыми типами (static_cast)
  • integer overflow

Объясните, что произойдет в следующих отрезках кода. Также, запустите данный код, чтобы убедиться в правильности вашей логики.

Там, где main не был расписан, код помещается в типичный основной файл:

#include <iostream>
#include <cstdint>
int main()
{
// сюда
}
int a = 5;
std::cout << a << std::endl;
Какой тип у a?

int. Тип идет перед именем переменной.

int означает, что в a можно будет записать целое число.

int a;
std::cout << a << std::endl;
Ответ

Данный код не скомпилируется, если подключить флаги -Werror и -Wall при компиляции. Если их не дать, скомпилируется и запустится, но результат возможно будет не такой, какой вы ожидаете.

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

Неинициализированная переменная в C++ это та переменная, куда еще не было нарочно записано значение.

Объявление переменной всего лишь выделяет ячейку памяти для этой переменной. Заметьте, что ячейка приходит из общей памяти и могла до этого использоваться другой переменной. Такие ячейки памяти остаются с их старым значением, которое было там при прошлом их использовании. По этой причине, в переменной может быть любое число, а вы как программист не можете полагаться на то, что там будет.

То, что находится в неинициализированной переменной по другому называется мусорные данные.

Почему в ячейке памяти не может не быть ничего?

Потому что память состоит из байтов, которые состоят из 8 битов каждый. Биты способны хранить в себе или 0, или 1, и ничто другое. Они не могут хранить в себе “ничего”. Соответственно и байты, состоящие строго из битов, не способны хранить что либо кроме 0 или 1 составляющих их битов.

Вы могли бы решить, что 0 это “ничего”, но это не всегда так. 0 можно вписать в бит нарочно.

Если 0 и “ничего” это одно и то же, то вы не сможете различить, который из них в бите, если в изоляции считаете 0 из бита. Был ли там 0 потому что туда еще ничего не вписали, или потому что кто-то туда нарочно его вписал ранее?

Что будет если считать из переменной a?

Поскольку a неинициализированная, вы получите то значение, которое было в памяти, выделенной a еще до того, как она была выделена a.

Однако, считывание из неинициализированной переменной считается UB (undefined behavior), что по определению означает, что может произойти все что угодно, а компилятору разрешается предположить, что такое действие невозможно.

int a = 5;
int a = 6;
std::cout << a << std::endl;
Правильный ответ:

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

int a = 5;
double a = 6; // ошибка тоже: переопределение `a`

Можно только перезаписать значение существующей переменной:

int a = 5;
a = 6;

4. Присваивание переменной к другой переменной

Заголовок раздела «4. Присваивание переменной к другой переменной»
int a = 5;
int b = 6;
a = b;
b = 7;
std::cout << a << std::endl;

5. Литералы, переменные и операторы — это выражения

Заголовок раздела «5. Литералы, переменные и операторы — это выражения»
int a = 5;
int b = a;
int c = a + 6;
std::cout << b << std::endl;
std::cout << c << std::endl;
Ответ

Литералы, чтения переменных и применения операторов к выражениям — всё это выражения. Каждое из них при вычислении даёт одно значение со своим типом:

  • 5 — это литерал, который сам является выражением: он при вычислении даёт самого себя и имеет тип int;
  • a на строчках int b = a; и int c = a + 6; — это чтение переменной, то есть выражение: оно даёт значение, которое сейчас хранится в a (здесь это 5);
  • a + 6 — это выражение с оператором: оператор +, применённый к двум выражениям (a и 6), даёт одно значение (11).

То есть int b = a; копирует в b результат выражения a, а int c = a + 6; копирует в c результат выражения a + 6.

Напечатается 5 и 11.

Какие выражения есть в этом фрагменте кода?

int a = 5;
int b = a + 6;
a = 7;
std::cout << b << std::endl;
Что произойдёт?

На строчке 2 в b записывается результат выражения с правой части присваивания (a + 6). Вычисление результата этого выражения означает его превращение в одно значение.

a + 6 -> 5 + 6 -> 11

Результатом вычисления выражения является значение 11, которое и будет записано в ячейку b.

Дальнейшее изменение a не влияет на предыдущую операцию, поскольку ее результат уже был сохранен в b.

Что здесь считается выражением?

Литерал (5, 6, 7), чтение переменной (a). Каждое из них при вычислении даёт одно значение со своим типом (здесь все int).

Даже всё присваивание целиком (a = 7) — это выражение. Почему? Потому что его результат можно присвоить, например, другой переменной.

Например, следующий код записывает 7 в a, вычисляя a = 7, которое само даёт 7 (то значение, которое установилось после присваивания), а оно затем записывается в c.

int c = (a = 7);

Даже b на строчке std::cout << b << std::endl; — это выражение, потому что его значение будет передано в функцию печати, а для этого оно должно быть сначала вычислено — например, в число — прежде чем отправиться в функцию печати.

int a = "abc";
Правильный ответ:

Ошибка несовместимости типов при компипляции.

Нельзя вписать значение строчного литерала "abc" в ячейку, хранящую int.

int a{5};
Правильный ответ:

Этот синтаксис по большей части эквивалентен следующему:

int a = 5;

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

Например, следующий код скомпилируется, если не дать компилятору никаких флагов. При запуске, в a будет сохранено число 5 (дробная часть будет отсечена).

int a = 5.6;

Если использовать фигурные скобки, не скомпилируется. Эта строгость может помочь нам заметить возможные ошибки.

int a{ 5.6 };
std::cout << sizeof(int) << std::endl;
std::cout << sizeof(uint8_t) << std::endl;
int a;
std::cout << sizeof(a) << std::endl;
Ответ:

sizeof вычисляется во время компиляции, давая размер переменной или типа в байтах. Во время выполнения его уже нет: компилятор заменяет sizeof(...) на обычное число, так что никакого sizeof в выполняемом коде уже не остаётся.

Например:

  • sizeof(int) даст 4;
  • sizeof(a) эквивалентен sizeof(тип_а), то есть sizeof(int), то есть 4;
  • sizeof(uint8_t) даст 1 (8 бит — 1 байт).

Операнд при этом на самом деле не вычисляется. В sizeof(a + 1) выражение a + 1 не вычисляется; компилятор определяет только тип его результата (здесь int), потому что типы существуют только во время компиляции и к моменту выполнения уже недоступны.

Какой тип будет у a в данном примере?

auto a = 5;
Что значит auto?

auto значит, что тип автоматически выводится из типа выражения, которым инициализируется переменная, а не просто из текста справа. Так как у выражения 5 тип int, у a тип автоматически станет int.

Можете представить, что auto как бы заменится на int во время компиляции.

Выведенный тип статический (фиксируется при компиляции) и поменяться позже не может:

auto a = 5;
a = "abc"; // ошибка: `a` — это `int`, позже он не может стать строкой
auto a;
a = 5;
Ответ

Это не скомпилируется: auto нужно выражение, из которого выводится тип. Без выражения компилятору нечем заменить auto. Присваивание a = 5; на следующей строке этого не исправляет — тип должен быть зафиксирован при определении и не может быть выведен из последующего присваивания.

int a{ 5 };
auto b{ a + 5 };
Ответ

auto учитывает тип всего выражения, а не то, является ли оно литералом. У a + 5 тип int, поэтому b станет int со значением 10. То же верно для любого выражения: auto c{ a }; тоже даст c тип int.

auto a{ 5 };
Ответ

То же, что и в прошлом примере.

auto a{ static_cast<uint8_t>(5) };
Что делает static_cast<uint8_t>?

static_cast<uint8_t>(5) это выражение, результатом которого является число 5 типа uint8_t.

  • static_cast говорит, “конвертируй результат выражения из одного типа в другой”.
  • <uint8_t> показывает, в какой тип нужно переконвертировать.
  • (5) в скобках задается выражение, результат которого нужно переконвертировать.

То есть произойдет следующее:

  • Вычисляется выражение из круглых скобок ((5)), получаем число 5 типа int.
  • Выполняется static_cast в тип, указанный внутри <...>, то есть в uint8_t. Поскольку 5 влезает в 1 байт, конвертируется без проблем.
  • static_cast<uint8_t>(5) заменяется на число 5 типа uint8_t.
  • auto воспринимает тип присваиваемого выражения, заменяется на uint8_t.
uint8_t a{ 5 };
int b{ static_cast<int>(a) };
Ответ

static_cast<int> здесь произойдет даже если его не написать. Так как любое число, сохраненное в a, всегда влезет в b, можно просто присвоить к b переменную a, что совершит конверсию из uint8_t в int автоматически.

#include <cstdint>
#include <iostream>
int main()
{
uint32_t a{ 256 };
uint8_t b{ static_cast<uint8_t>(a) };
uint32_t c{ b };
std::cout << c << std::endl;
}
Что делает static_cast<uint8_t>?

В данном примере, он берет только младший байт числа от a, отсекая старшие 3 байта. Это называется truncation.

Если записать это без static_cast<uint8_t>, по умолчанию он произойдет автоматически. При этом компилятор не дает ошибок, если не переданы флаги предупреждения при компиляции.

Чтобы подобные ситуации определялись и останавливались компилятором, необходимо передать флаги предупреждений, компилируя, например, вот так:

g++ test.cpp -Wall -Werror -Wconversion

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

uint8_t b { 256 }; // narrowing conversion

А следующее вероятно даст предупреждение:

uint32_t a { 256 };
uint8_t b { a }; // narrowing conversion
Правильный ответ:

static_cast<uint8_t> обрезает значение a (truncation), оставляя только последний байт (наименьший байт).

Ответ будет 0, потому что число 256 имеет запись 1 0000 0000 в двоичной системе, а обрезание этого числа до 8 бит оставит лишь 0000 0000, удалив старшую 1.

Что будет, если в a сохранить другое значение?
  • Значение 257: Сохранится 1 0000 0001, что оставит 0000 0001 при отсечении.
  • Значение 258: Сохранится 1 0000 0010, что оставит 0000 0010 при отсечении.
  • Значение 511: Сохранится 1 1111 1111, что оставит 1111 1111 при отсечении.
  • Значение 512: Сохранится 10 0000 0000, что оставит 0000 0000 при отсечении.

17. Побитовая операция (продвинутый уровень)

Заголовок раздела «17. Побитовая операция (продвинутый уровень)»
#include <cstdint>
#include <iostream>
int main()
{
uint8_t a{ 0 };
uint8_t b{ ~a };
int32_t c{ b };
std::cout << c;
}

Что делает ~?

Это побитовый оператор, который переделывает все 0 в 1 в двоичном представлении числа. Например, 1010 0011 -> 0101 1100.

Правильный ответ
  • 0 сохраняется в переменную на 8 бит.
  • Оператор ~ применится на 8-битный 0: 0000 0000 -> 1111 1111 (255)
  • Результат сохраняется в 8-битную b.
  • Результат пересохраняется как есть в 32-битный c (для печати).
#include <cstdint>
#include <iostream>
int main()
{
uint8_t a{ 255 };
int8_t b{ static_cast<int8_t>(a) };
int32_t c{ b };
std::cout << c;
}

Что делает static_cast<int8_t>?

В данном примере, он воспринимает представление числа сохраненное в a как есть, но со знаком.

Например, если a равен 0, в ответе будет 0, потому что 0000 0000 что как целое со знаком, что без знака, является значением 0.

Если a равен 128, то есть 1000 0000, станет -128, потому что 1000 0000 как число со знаком это -128.

Что делает присваивание к int32_t для меньшего типа со знаком int8_t?

Если значение отрицательное, ответ тоже будет отрицательным (верхние биты заполнятся 1). Например, -1 записывается как 1111 1111 в 8 битах, станет 1111 1111 1111 1111 1111 1111 1111 1111 в 32 битах, что тоже равно -1.

Если значение положительное, ответ тоже будет положительным (верхние биты заполняются 0). Например, 10 записывается как 0000 1010 в 8 битах, станет 0000 0000 0000 0000 0000 0000 0000 1010 в 32 битах, что тоже равно 10.

Короче говоря, в int32_t будет всегда храниться то же числовое значение.

Правильный ответ:
  • 255 записывается в a как 1111 1111.
  • 1111 1111 конвертируется как есть в b, а 1111 1111 как число со знаком — -1
  • значение -1 сохраняется в c как значение -1 (см. выше объяснение как).
int a { 1 };
int b { 2 };
a = b;
b = a;
std::cout << a << std::endl;
std::cout << b << std::endl;
Ответ

Похожий код с первого взгляда выглядит как попытка сменить значения a и b местами, чтобы в a было 2, а в b было 1.

Однако, a = b перезаписывает a, и старое значение 1 из a пропадает.

Правильный код был бы:

int a { 1 };
int b { 2 };
// временная переменная
int temp { a };
a = b;
b = temp;