Посмотр рубрик

Типы данных

C/C++ - язык со строгой типизацией, т.е. существует несколько типов данных, каждый тип может хранить данные своего "формата" и размера. Стандартные типы данных могут конвертироваться между собой, об этом - в следующем уроке.

Логика #

Тип bool может хранить только 1 и 0 или специальные константы true (правда) и false (ложь), нужен для удобства работы с логическими выражениями и величинами:

bool var = true;

Несмотря на свою двоичность, bool имеет вес не 1 бит, а 1 байт, т.к. это минимальная адресуемая ячейка памяти. Хранить 1-бит значения тоже можно, см. урок про битовые флаги.

Символы #

Тип char хранит код символа из стандартной таблицы символов ASCII, подробнее об этом поговорим в уроке про строки.

char var = 'a';

Целые числа #

Целочисленные типы данных имеют лимиты по хранимым значениям, а также некоторые тонкости при вычислениях. Эти особенности можно просто выучить, но гораздо лучше будет разобраться в том, как хранятся целые числа - тогда всё покажется очень логичным и учить ничего не придётся.

Диапазон значений #

Диапазон допустимых значений целочисленного типа зависит от его размера и "знаковости" - поддержки отрицательных чисел. Чем больше места занимает тип - тем больше уникальных значений он может хранить, это количество равно 2^n, где n - размер типа в битах. По сути это - количество возможных комбинаций нулей и единиц. Например 3 бита - 2^3 = 8 возможных комбинаций: 000, 001, 010, 011, 100, 101, 110, 111. Или 8 бит: 2^8 = 256 комбинаций.

Размеры стандартных типов данных идут по степени двойки:

Знаковый Вес, бит От До
Да 8 -128 127
Нет 8 0 255
Да 16 -32 768 32 767
Нет 16 0 65 535
Да 32 -2 147 483 648 2 147 483 647
Нет 32 0 4 294 967 295
Да 64 -9 223 372 036 854 775 808 9 223 372 036 854 775 807
Нет 64 0 18 446 744 073 709 551 615

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

Диапазоны значений стандартных типов хранятся в константах, которые можно использовать в программе. Иногда это помогает избавиться от лишних вычислений:

  • UINT8_MAX
  • INT8_MAX
  • UINT16_MAX
  • INT16_MAX
  • UINT32_MAX
  • INT32_MAX
  • UINT64_MAX
  • INT64_MAX
  • SIZE_MIN
  • SIZE_MAX

Для использования этих констант на некоторых платформах нужно подключить в код стандартную библиотеку #include <stdint.h> или #include <limits>

Беззнаковые числа #

Так как данные хранятся в двоичном виде, то они уже представляют собой число в диапазоне от 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 - отрицательное, такой тип хранения называется дополнительным кодом (two's complement). Таким образом, на хранение самого числа остаётся на 1 бит меньше места и диапазон значений делится пополам - от -2^(n-1) до 2^(n-1) - 1. Например для 8 бит получится диапазон десятичных чисел от -128 до 127. Типы данных с поддержкой знака числа называются знаковыми (signed).

Давайте теперь посчитаем от числа 3 DEC вниз с шагом 1 по обычным правилам вычитания в ячейке 8 бит:

0b00000011  // 3
0b00000010  // 2
0b00000001  // 1
0b00000000  // 0
0b11111111  // -1
0b11111110  // -2

Данные у нас ограничены одним байтом, поэтому при переходе через 0 пришлось "добавить" слева старший разряд 1 и сразу его убрать, т.е. 100000000 - 1 = 11111111 - ячейка переполнилась снизу.

Вот и появилась слева единица, которая отвечает за знак числа! Да, старший бит не просто отвечает за знак, он получается автоматически, то есть число хранится ровно так, как оно и представлено в двоичной системе. Именно поэтому компилятору нужно конкретно указывать, как интерпретировать данные - как беззнаковое или как знаковое число, потому что одни и те же данные будут иметь разное значение: 0b11111110 как беззнаковое - это десятичное 254, а как знаковое - десятичное -2.

Благодаря такому представлению данных все арифметические операции выполняются быстро и по обычным правилам сложения чисел. Более того, процессору не нужна дополнительная операция "вычитание", потому что вычитание - это сложение с отрицательным числом, оно происходит автоматически по обычным правилам сложения.

Типы short, int, long #

Несмотря на то, что этих типов всего 3, синтаксис позволяет комбинировать их и получить целый "зоопарк" одинаковых типов. Это нужно понять, принять и не дать себя запутать

Стандарт языка задаёт только минимумы для этих типов, т.е. максимум зависит от платформы и компилятора:

  • short - минимум 16 бит
  • int - минимум 16 бит
  • long - минимум 32 бит
  • long long - минимум 64 бит (это отдельный тип)

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

  • signed - знаковый, поддерживать отрицательные числа
  • unsigned - беззнаковый, не поддерживать отрицательные числа

То есть signed int аналогичен int и поддерживает отрицательные числа, а unsigned int - не поддерживает.

Модификатор знаковости является самостоятельным типом и синонимом int, то есть signed аналогичен signed int, а unsigned - unsigned int.

Тип int является базовым целочисленным типом и остальные типы можно рассматривать как модификаторы для него: long int аналогичен long.

Вот для наглядности все возможные комбинации создания 8-ми основных типов:

  • int, signed, signed int - знаковый int
  • unsigned, unsigned int - беззнаковый int
  • short, short int, signed short, signed short int - знаковый short
  • unsigned short, unsigned short int - беззнаковый short
  • long, long int, signed long, signed long int - знаковый long
  • unsigned long, unsigned long int - беззнаковый long
  • long long, long long int, signed long long, signed long long int - знаковый long long
  • unsigned long long, unsigned long long int - беззнаковый long long
unsigned var = 1234;
int i = -123;

Тип size_t #

Тип size_t (size, размер) - беззнаковый целочисленный тип, размер которого ограничен максимальным весом данных, которые могут существовать на текущей платформе: размер другого типа данных, длина массива, максимальный адрес в памяти. Например:

  • AVR - 16 бит
  • ESP - 32 бит
  • ПК - 64 бит

size_t обычно используется там, где подразумевается размер данных (количество байт) - для читаемости программы

size_t size = 1234;

Тип char #

Несмотря на то, что это символьный тип, с модификатором знака он становится отдельным типом данных для хранения целых чисел:

  • signed char - знаковый 8 бит
  • unsigned char - беззнаковый 8 бит
unsigned char var = 123;

Знаковость самого char зависит от платформы и компилятора, под капотом он может быть как signed, так и unsigned

Короткие названия #

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

  • intN_t - знаковые
  • uintN_t - беззнаковые

где N - размер в количестве бит: 8, 16, 32 или 64. Компилятор подставляет вместо этих типов стандартные типы в зависимости от платформы, на которой компилируется код - например вместо uint8_t подставит unsigned char.

Я использую короткие названия, потому что:

  • Название более короткое в большинстве случаев
  • Проще ориентироваться в максимальных значениях
  • Проще изменить размер или знаковость
  • Вес переменной задан явно и не зависит от платформы
uint16_t var = 1234;

Для использования коротких названий нужно подключить в код стандартную библиотеку #include <stdint.h>

Вещественные числа #

Вещественные числа - числа, имеющие целую и дробную части. Во многих вычислениях удобно использовать такие числа, так как не всегда работа идёт с целыми - температура, различные коэффициенты, тригонометрия и так далее. В C/C++ есть поддержка таких чисел, они называются числами с плавающей точкой (floating point) и хранятся в экспоненциальном виде согласно стандарту IEEE 754. Этот способ позволяет хранить как очень маленькие, так и очень большие числа, но с конечной точностью:

Вес, бит Точность, знаков Диапазон значений
32 ~7 от ±1.4×10^-45 до ±3.4×10^38
64 ~16 от ±4.9×10^-324 до ±1.8×10^308

Можно заметить, насколько огромный диапазон значений даже на 32 битах: от сверхмаленьких чисел (45 нулей после запятой 0.000<...>001) до огромных - 38 нулей в целой части: 1234<...>456.00. А занимает всего 4 байта!

В C/C++ они представлены типами:

  • float - 32 бит
  • double - 64 бит (32 бит на слабых МК, например AVR)
  • long double - зависит от платформы
float var = 3.14;

Поддержка вычислений #

Поддержка таких чисел и операций с ними может быть аппаратной на уровне устройства процессора, если у него есть блок FPU (Floating Point Unit) - тогда работа с такими числами выполняется очень быстро и не утяжеляет программу (например ESP32 и многие STM32). Если FPU отсутствует - компилятор автоматически будет использовать встроенную программную реализацию (по сути библиотеку) - она медленная и занимает много памяти, например около 1.5 КБ на МК AVR.

Формат записи #

В C/C++ поддерживается несколько форматов записи чисел с плавающей точкой:

Тип записи Пример Чему равно
Десятичная дробь 20.5 20.5
Научный 2.34E5 2.34*10^5 (234000)
Инженерный 67e-12 67*10^-12 (0.000000000067)

После десятичной точки может не быть цифры - число 12. корректно и равносильно 12.0

Точность #

Переменная с плавающей точкой хранит не то число, которое в неё записали или которое получилось в результате математической операции, а близкое к нему в пределах точности - у float точность 6-7 старших разрядов:

float v;
v = 123456.654321;    // 123456.656250
v = 0.0123456789;     // 0.0123456788
v = 0.0000123456789;  // 0.0000123456788
v = 123456789;        // 123456792.0

Обратите внимание на количество старших разрядов, которые совпадают с записанным числом. То есть "для печати" это уже совсем другое число, но для дальнейших вычислений - очень близкое к оригинальному. Именно поэтому float числа нельзя сравнивать строго (об этом в другом уроке) - их значения могут отличаться от ожидаемых.

Знаков после запятой #

У самого float нет характеристики "количество знаков после запятой" - дробная часть условно бесконечная и можно получить сколько угодно знаков после запятой, но значимыми будут только те, которые вошли в точность. Это означает, что "количество знаков после запятой" появляется только при выводе числа для чтения - в консоль или на дисплей. Например в Arduino печать в монитор порта по умолчанию выводит два знака, но можно вывести и 10, и 50.

Новички часто путаются в этом моменте. Здесь нужно просто запомнить, что "знаков после запятой" - это для человека, а для вычислений и работы с переменными в программе этой характеристики нет - вычисления сами по себе выполняются с максимально возможной точностью

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. Подробнее - в уроке про сравнение

Таблица типов #

Прилагаю таблицу значений для всех типов применительно к Arduino и embedded в целом:

Тип Синоним Вес, бит От До
bool - 8 0, false 1, true
signed char int8_t 8 -128 127
unsigned char uint8_t 8 0 255
int - 16/32*
short int16_t 16 -32 768 32 767
unsigned short uint16_t 16 0 65 535
long int32_t 32 -2 147 483 648 2 147 483 647
unsigned long uint32_t 32 0 4 294 967 295
long long int64_t 64 -9 223 372 036 854 775 808 9 223 372 036 854 775 807
unsigned long long uint64_t 64 0 18 446 744 073 709 551 615
float - 32 ±1.4×10^-45 ±3.4×10^38
double - 64** ±4.9×10^-324 ±1.8×10^308
  • * - на AVR 16 бит, на ESP 32 бит
  • ** - на AVR 32 бит, на ESP 64 бит

sizeof #

Существует специальный оператор sizeof - он позволяет внутри программы узнать размер любой сущности, которая имеет вес, в том числе размер типа данных. Возвращает вес в количестве байт, тип size_t:

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;

Endianness* #

Данные хранятся в памяти побайтно: например, если тип данных имеет размер 4 байта - значение будет разбито на 4 байта и записано в память друг за другом. Возьмём число 0x12345678 - имеем в памяти 4 соседних байта со значениями 0x12, 0x34, 0x56 и 0x78. В каком порядке они расположены? Это зависит от платформы, на которой выполняется код. Существует два варианта:

  • big-endian - старший байт числа хранится по меньшему адресу в памяти, то есть если представить, что адрес увеличивается слева направо, то наше число будет храниться так же, как записано: 12 34 56 78
  • little-endian - младший байт числа хранится по меньшему адресу в памяти, то есть наоборот: 78 56 34 12

Это играет большую роль, когда устройство получает данные от другого устройства с другим порядком байтов - можно записать данные "не той стороной". На практике оно скорее всего не встретится, но о самом понятии знать нужно, оно указывается в документации на конкретную архитектуру процессора. На МК чаще встречается little-endian.

Дополнительно #

Дополнительный контент доступен владельцам набора GyverKIT и по подписке, подробнее читай здесь. Блок содержит:

  • Тезисы, Вопросы, Примеры (Arduino)
  • 1 блоков кода

Полезные страницы #

(27 голосов)
Подписаться
Уведомить о
guest

5 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх