Несмотря на то, что элементарной единицей измерения цифровой информации является бит, память организована в байтах (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- знаковый intunsigned,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 |
signed char |
int8_t |
1 | -128 | 127 |
unsigned char |
uint8_t |
1 | 0 | 255 |
int |
- | 2 или 4 | ||
short |
int16_t |
2 | -32 768 | 32 767 |
unsigned short |
uint16_t |
2 | 0 | 65 535 |
long |
int32_t |
4 | -2 147 483 648 | 2 147 483 647 |
unsigned long |
uint32_t |
4 | 0 | 4 294 967 295 |
long long |
int64_t |
8 | -9 223 372 036 854 775 808 | 9 223 372 036 854 775 807 |
unsigned long long |
uint64_t |
8 | 0 | 18 446 744 073 709 551 615 |
Лимиты #
Также максимальные значения хранятся в константах, которые можно использовать в коде. Иногда это помогает избавиться от лишних вычислений:
UINT8_MAXINT8_MAXUINT16_MAXINT16_MAXUINT32_MAXINT32_MAXUINT64_MAXINT64_MAX
Для использования этих констант на некоторых платформах нужно подключить в код стандартный файл #include <stdint.h> или #include <limits>
Тип size_t #
Есть ещё целочисленный беззнаковый тип данных size_t (size, размер), вес которого зависит от архитектуры и платформы, на которой выполняется код - значение ограничено максимальным размером данных, которые могут существовать в программе (размер другого типа данных, длина массива, максимальный адрес в памяти). Например на МК AVR это 2 байта, на ESP - 4 байта.
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 байт, как у double
Формат записи #
В 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
Переменная в значении NAN не сравнивается с другими числами - результат всегда false. Подробнее - в уроке про сравнение
sizeof #
Существует специальный оператор sizeof - он позволяет внутри программы узнать размер любой сущности, которая имеет вес, в том числе размер типа данных. Возвращает вес в количестве байт, тип size_t:
sizeof(int); // 2 или 4 байта
sizeof(short); // 2 байта
sizeof(char); // 1 байт
sizeof(foo); // размер переменной foo
Оператор sizeof может применяться к переменным без скобок, например sizeof var
Тип void #
В C/C++ есть специальный тип данных void (пустота) - служебный пустой тип данных. Нельзя создать переменную такого типа, но сам тип используется в некоторых особых случаях, о которых будет рассказано в следующих уроках.
Свой тип #
В C/C++ есть возможность создать тип данных со своим названием на основе существующего типа при помощи оператора typedef (C/C++) или using (C++):
typedef int my_int; // синоним для int
using my_long = long; // синоним для long
my_int v1;
my_long v2;
Дополнительно #
Дополнительный контент доступен владельцам набора GyverKIT и по подписке, подробнее читай здесь. Блок содержит:
- Тезисы, Вопросы, Примеры (Arduino)
- 1 блоков кода
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками