Несмотря на то, что элементарной единицей измерения цифровой информации является бит, память организована в байтах (8 бит), то есть байт - минимальная адресуемая ячейка памяти, которую можно записать и прочитать.
Читать и писать данные размером в 1 бит тоже можно, но не "стандартными" средствами - об этом поговорим в следующих уроках
Размеры стандартных типов данных следуют за степенью двойки - 1, 2, 4 и 8 байт или 8, 16, 32 и 64 бит соответственно. Технически возможно хранить данные любого другого размера, например с целью экономии места, но опять же нестандартными средствами и менее эффективно.
Логический тип bool #
В C++ есть логический тип bool
, который может хранить только 1
и 0
, или же специальные константы true
(правда, 1) и false
(ложь, 0). Несмотря на свою двоичность, bool
занимает 1 байт, то есть 8 бит. Этот тип добавлен для удобства работы с логическими выражениями и величинами.
В языке C этого типа нет, вместо него используют обычный целочисленный тип и костыли
Целочисленные типы #
Диапазон допустимых значений целочисленного типа зависит от его размера и поддержки отрицательных чисел. Чем больше места занимает тип - тем больше уникальных значений он может хранить, это количество равно 2^n
, где n
- размер типа в количестве бит. По сути это - количество возможных комбинаций нулей и единиц при заданном количестве бит. Например 3 бита - 2^3 = 8 возможных комбинаций: 000, 001, 010, 011, 100, 101, 110, 111. Или 8 бит: 2^8 = 256 комбинаций.
Беззнаковые числа #
Так как данные хранятся в двоичном виде, то они уже представляют собой число в диапазоне от 0
до 2^n - 1
, так как счёт начинается с нуля. То есть 8 бит тип может хранить числа от 0
до 255
, 16 бит - от 0
до 65535
и так далее по аналогии. Типы данных, которые хранят только положительные числа, называются беззнаковыми (unsigned).
Что будет, если превысить допустимое значение? Давайте возьмём ячейку 1 байт (8 бит) и посчитаем вверх с шагом 1 по обычным правилам сложения, начиная с числа 0b11111110
(254 DEC):
0b11111110 // 254
0b11111111 // 255
0b00000000 // 0 - 0b100000000, но старший разряд не уместится!
0b00000001 // 1
Всё верно, значение обнулится, так как старший разряд хранить уже негде - мы рассматриваем ячейку с размером 8 бит. В таком случае говорят, что ячейка переполнилась сверху.
При уменьшении числа это произойдёт в обратном порядке - из 0b00000000
получится 0b11111111
, то есть ячейка переполнится снизу. Таким образом в рамках 1 байтного типа данных 255 + 1 = 0
, а 0 - 1 = 255
.
Знаковые числа #
Как хранить в памяти отрицательное число? Чтобы не потерять знак числа, нужно его тоже где-то хранить. У двоичных числел знак хранится в старшем бите: 0 - положительное число, 1 - отрицательное. Таким образом, на хранение самого числа остаётся на 1 бит меньше места и диапазон значений делится пополам - от -2^(n-1)
до 2^(n-1) - 1
. Например для 8 бит получится диапазон десятичных чисел от -128
до 127
. Типы данных с поддержкой знака числа называются знаковыми (signed).
Давайте теперь посчитаем от числа 3 вниз с шагом 1 по обычным правилам вычитания, в ячейке 8 бит:
0b00000011 // 3
0b00000010 // 2
0b00000001 // 1
0b00000000 // 0
0b11111111 // -1
0b11111110 // -2
Данные у нас ограничены одним байтом, поэтому при переходе через 0 пришлось "добавить" слева старший разряд 1 и сразу его убрать, т.е. 100000000 - 1 = 11111111 - ячейка переполнилась снизу.
Вот и появилась слева единица, которая отвечает за знак числа! Да, старший бит не просто отвечает за знак, он получается автоматически, то есть число хранится ровно так, как оно и представлено в двоичной системе. Именно поэтому компилятору нужно конкретно указывать, как интерпретировать данные - как беззнаковое или как знаковое число, потому что одни и те же данные будут иметь разное значение: 0b11111110
как беззнаковое - это десятичное 254
, а как знаковое - десятичное -2
.
Математически такой тип хранения называется дополнительным кодом: чтобы взять положительное число с отрицательным знаком, нужно:
- Поставить старший бит в
1
- Инвертировать остальные биты (заменить
0
на1
и1
на0
) - Прибавить к ним
1
Например число 3
= 0b00000011
-> 0b11111100
-> 11111101
= -3
. Как можно увидеть из примера с вычитанием выше - числа подчиняются этой формуле.
Благодаря такому представлению данных все арифметические операции выполняются быстро и по обычным правилам сложения чисел. Более того, процессору не нужна дополнительная операция "вычитание", потому что вычитание - это сложение с отрицательным числом, оно происходит автоматически по обычным правилам сложения.
Endianness #
Данные хранятся в памяти побайтно, например если тип данных имеет размер 4 байта - значение будет разбито на 4 байта и записано в память друг за другом. Возьмём число 0x12345678
- имеем в памяти 4 соседних байта со значениями 0x12
, 0x34
, 0x56
и 0x78
. В каком порядке они расположены? Это зависит от платформы, на которой выполняется код. Существует два варианта:
- big-endian - старший байт числа хранится по меньшему адресу в памяти, то есть если представить, что адрес увеличивается слева направо, то наше число будет храниться так же, как записано:
12 34 56 78
- little-endian - младший байт числа хранится по меньшему адресу в памяти, то есть наоборот:
78 56 34 12
Это играет большую роль, когда устройство получает данные от другого устройства с другим порядком байтов - можно записать данные "не той стороной". На практике оно скорее всего не встретится, но о самом понятии знать нужно, оно указывается в документации на конкретную железку.
Тип int #
Базовый целочисленный тип данных - int
(integer). Размер этого типа зависит от платформы, на которой компилируется код, и может быть 2 или 4 байта.
Например на AVR это 2 байта, на ESP и STM32 - 4 байта
По умолчанию int
- это знаковый тип, то есть поддерживает отрицательные числа. У int
есть модификаторы:
short
- занимать 2 байтаlong
- занимать 4 байтаunsigned
- беззнаковый - не поддерживать отрицательные числаsigned
- знаковый - поддерживать отрицательные числа
Из этих модификаторов, как из конструктора, можно собрать себе нужный целочисленный тип. Также модификаторы и сами по себе работают как самостоятельные типы, например unsigned
будет размером с int
, но беззнаковым. Я приведу все примеры для наглядности, потому что в чужом коде можно встретить всякое и нужно быть к этому готовым:
int
,signed
,signed int
- синонимы int, знаковыйunsigned
,unsigned int
- беззнаковый intshort
,signed short
,signed short int
- 2 байта знаковыйunsigned short
,unsigned short int
- 2 байта беззнаковыйlong
,signed long
,signed long int
- 4 байта знаковыйunsigned long
,unsigned long int
- 4 байта беззнаковыйlong long
,long long int
,signed long long
,signed long long int
- 8 байт знаковыйunsigned long long
,unsigned long long int
- 8 байт беззнаковый
Довольно запутанная история!
Тип char #
Тип char
весит 1 байт и хранит код символа из стандартной таблицы символов ASCII, подробнее об этом поговорим в уроке про строки. Тип char
поддерживает модификаторы знака:
signed char
- 1 байт знаковыйunsigned char
- 1 байт беззнаковый
В отличие от signed int
, который является синонимом int
, signed char
- отдельный тип данных! Таким образом в нём можно хранить целые числа размером до 1 байт, что позволяет сэкономить место.
Короткие названия #
К счастью, у целочисленных типов есть удобные короткие названия, которые сразу отражают знаковость и размер: intN_t
- знаковые, uintN_t
- беззнаковые, где N
- размер в количестве бит: 8, 16, 32 или 64. Компилятор подставляет вместо этих типов стандартные типы в зависимости от платформы, на которой компилируется код (например вместо uint8_t
подставит unsigned char
). Я использую короткие названия, потому что:
- Название более короткое в большинстве случаев
- Проще ориентироваться в максимальных значениях
- Проще изменить один тип на другой
- Размер переменной задан конкретно и не зависит от платформы
Для использования коротких названий нужно подключить в код стандартный файл #include <stdint.h>
Тип | Вес, байт | От | До |
---|---|---|---|
bool |
1 | 0, false |
1, true |
int8_t (signed char) |
1 | -128 | 127 |
uint8_t (unsigned char) |
1 | 0 | 255 |
int |
2 или 4 | ||
int16_t (short) |
2 | -32 768 | 32 767 |
uint16_t (unsigned short) |
2 | 0 | 65 535 |
int32_t (long) |
4 | -2 147 483 648 | 2 147 483 647 |
uint32_t (unsigned long) |
4 | 0 | 4 294 967 295 |
int64_t (long long) |
8 | -9 223 372 036 854 775 808 | 9 223 372 036 854 775 807 |
uint64_t (unsigned long long) |
8 | 0 | 18 446 744 073 709 551 615 |
Также максимальные значения хранятся в константах, которые можно использовать в коде. Иногда это помогает избавиться от лишних вычислений:
UINT8_MAX
INT8_MAX
UINT16_MAX
INT16_MAX
UINT32_MAX
INT32_MAX
UINT64_MAX
INT64_MAX
Для использования этих констант на некоторых платформах нужно подключить в код стандартный файл #include <stdint.h>
или #include <limits>
Тип size_t #
Есть ещё целочисленный беззнаковый тип данных size_t
(размер), вес которого зависит от платформы, на которой выполняется код, и ограничен максимальным размером данных, которые могут существовать в программе (размер другого типа данных, массива, максимальный адрес в памяти). Например на МК AVR это 2 байта, на ESP - 4 байта.
size_t
обычно используется там, где подразумевается размер данных, количество байт - для читаемости кода. Оператор sizeof
(о нём ниже) также возвращает тип данных size_t
.
Вещественные числа #
Вещественные числа, они же десятичные дроби - числа, имеющие целую и дробную части. Во многих вычислениях удобно использовать дробные числа, так как не всегда работа идёт с целыми числами, банальный пример - температура воздуха. В C/C++ есть поддержка таких чисел, называются они числами с плавающей точкой (floating point). Подробно рассматривать хранение таких в памяти чисел мы не будем, оно довольно непростое. Достаточно знать, что число с плавающей точкой хранится не в явном виде, а "зашифровано" - хранится в экспоненциальном представлении согласно стандарту IEEE 754.
Поддержка таких чисел и операций с ними может быть аппаратной на уровне устройства процессора, если у него есть блок FPU (Floating Point Unit) - тогда работа с такими числами выполняется очень быстро и не утяжеляет программу. Если FPU отсутствует - компилятор автоматически будет использовать встроенную программную реализацию - она медленная и занимает много памяти, например около 1.5 КБ на контроллерах AVR.
Данные с плавающей точкой представлены типами float
- обычной и double
- двойной точности:
Тип | Вес, байт | Точность, знаков | Диапазон значений |
---|---|---|---|
float |
4 | ~7 | -3.4E+38… 3.4E+38 |
double |
8* | ~16 | -1.7E+308.. 1.7E+308 |
* - на некоторых платформах, например AVR, double
имеет вес 4 байта и полностью равноценен float
Есть ещё тип long double
, его вес тоже зависит от компилятора и конкретной архитектуры. Обычно те же 8 байт, как у long
Формат записи #
В C/C++ поддерживается несколько форматов записи чисел с плавающей точкой:
Тип записи | Пример | Чему равно |
---|---|---|
Десятичная дробь | 20.5 |
20.5 |
Научный | 2.34E5 |
2.34*10^5 или 234000 |
Инженерный | 67e-12 |
67*10^-12 или 0.000000000067 |
После десятичной точки может не быть цифры - число 12.
корретно и равносильно 12.0
Точность #
Из таблицы выше можно заметить, что диапазон возможных значений очень широкий - благодаря хранению в экспоненциальном виде. В то же время это ограничивает возможную точность хранения чисел: примерно 7 знаков для float
и 16 - для полноценного double
. Из этого можно сделать важный вывод - в память запишется не ровно то число, которое мы указали, а близкое к нему в пределах точности. Пример для float
, где в комментарии указано реальное значение переменной:
v = 123456.654321; // 123456.656250
v = 0.0123456789; // 0.0123456788
v = 0.0000123456789; // 0.0000123456788
v = 123456789; // 123456792.0
Обратите внимание на количество старших разрядов, которые совпадают с записанным числом - оно +- совпадает с точностью из таблицы выше.
NaN и inf #
Помимо численного значения данные с плавающей точкой могут иметь ещё два специальных - NaN и inf:
- NaN - дословно - "не число" (Not a Number), ошибка данных, которая могла произойти в результате вычисления или присвоена вручную при помощи константы
NAN
- inf - бесконечно большое значение (infinity), которое могло получиться в результате вычислений (деление на 0) или присвоена вручную при помощи константы
INFINITY
Текстовое представление этих значений можно получить, выведя переменную в текстовый формат для отладки или при печати в консоль. Чтобы определить в программе, имеет ли переменная одно из этих значений, есть соответствующие функции isnan()
и isinf()
.
v = NAN;
isnan(v); // true
v = INFINITY;
isinf(v); // true
Размер типа #
Существует специальный оператор sizeof
- он позволяет внутри программы узнать размер любой сущности, которая имеет вес, в том числе размер типа данных:
sizeof(int); // 2 или 4 байта
sizeof(short); // 2 байта
sizeof(char); // 1 байт
Тип void #
В C/C++ есть специальный тип данных void
(пустота) - служебный пустой тип данных. Нельзя создать переменную такого типа, но сам тип используется в некоторых особых случаях, о которых будет рассказано в следующих уроках.
Свой тип #
В C/C++ есть возможность создать тип данных со своим названием на основе существующего типа при помощи оператора typedef
:
typedef int MyType; // MyType - синоним для int
MyType var = 0;
Основные тезисы урока #
- Минимальной адресуемой ячейкой памяти является 1 байт, т.е. 8 бит данных
- Размеры стандартных типов данных идут по степени двойки - 1, 2, 4 и 8 байт
- bool - логический тип, может хранить только 0 и 1, но занимает так же 1 байт
- Unsigned - только положительные числа
- Signed - поддержка отрицательных чисел
- Данные типа с весом больше 1 байта хранятся в соседних байтах друг за другом
- Big-endian - старший байт числа хранится по меньшему адресу в памяти
- Little-endian младший байт числа хранится по меньшему адресу в памяти
- Тип int занимает разный размер на разных платформах
- Тип char является символьным, но в нём можно хранить и обычные числа
- Типы float и double хранят числа с плавающей точкой
- Хранение дробных чисел имеет конечную точность
- Дробные числа имеют особые значения - NaN и inf