Типы данных в языке C
Представление чисел
Порядок байт
Оперативная память компьютера разбивается на ячейки, каждая ячейка хранит некоторую порцию информации размером в один байт. Следовательно каждая ячейка в оперативной памяти равна одному байту. У каждой ячейки также есть свой адрес, благодаря которому в программе можно ссылаться на эту ячейку по адресу. Адресация в памяти обычно происходит слева направо, т.е адреса в памяти увеличиваются от младших байтов к старшим(от меньших адресов к большим)
В оперативной памяти для хранения многобайтовых значений используется порядок байт. Выбор порядка байт зависит от архитектуры процессора, и программы, написанные для одной архитектуры, могут работать некорректно на другой, если предполагается определенный порядок байт.
Данный порядок(endianness) бывает двух типов
Big-endian
big-endian(прямой порядок байт) - это привычный порядок записи чисел.
Возьмем число в шестнадцатеричном формате 0x1234. Данное число в памяти будет занимать 2 байта и если архитектура процессора подразумевает прямой порядок хранения байт, тогда в памяти данное число будет сохранено в том же порядке что и при записи 0x1234.
Если представить что адреса ячеек нумеруются от 0, то выглядело бы это следующим образом:
Aдрес | байт 0 | байт 1 |
---|---|---|
a | 12 | 34 |
Старший байт (0x12) занимает младший адрес (a0), младший байт (0x34) занимает старший адрес (a1)
Little-endian
little-endian(обратный порядок байт) - это противоположность привычному порядку записи.
При данном порядке байт младшие байты хранятся в младших адресах, а старшие в старших. Если представить число 0x1234 в этом порядке в памяти, тогда получим следующую картину:
Aдрес | байт 0 | байт 1 |
---|---|---|
a | 34 | 12 |
Старший байт (0x12) занимает старший адрес (a1), младший байт (0x34) занимает младщий адрес (a0)
Биты внутри байта нумеруется справа налево, в то время как сами байты в памяти нумеруются слева направо
Типы данных
Язык C является статически типизированным языком программирования.
Переменная - это поименованная область памяти. Хранилище каких-то данных, которое может изменять своё значение в процессе выполнения программы. Каждая переменная имеет свое имя (идентификатор), которое позволяет обращаться к ней в коде программы. Переменные могут содержать данные различных типов.
Тип данных определяет какие значения могут принимать переменные и какие операции можно выполнять с этими значениями.
Знаковые целочисленные типы
Всего стандарт определяет пять знаковых целочисленных типов:
Тип | Синоним |
---|---|
signed char | - |
int | signed, signed int |
short | short int, signed short, signed short int |
long | long int, signed long, signed long int |
long long(C99) | long long int, signed long long, signed long long int |
Для каждого из пяти знаковых целочисленных типов из таблицы, имеется соответствующий беззнаковый тип, который занимает такое же количество памяти, с тем же выравниванием. Другими словами, если компилятор выравнивает объекты signed int
по четным адресам байтов, то по четным адресам байтов будут выровнены и объекты unsigned int
Многие процессоры требуют выравнивания данных в памяти чтобы определенные типы данных начинались с адреса, кратного их размеру в байтах. Это улучшает производительность, позволяя процессорам более эффективно обращаться к памяти, когда данные выровнены по определенным границам.
Тип char
также является одним из стандартных целочисленных типов. Однако однословное имя типа char
ассоциируется, в зависимости от компилятора, с signed char
или unsigned char
. Поскольку этот выбор
отдан на усмотрение реализации, char
, signed char
, unsigned char
формально являются тремя различными типами.
Беззнаковые целочисленные типы
Тип | Синоним |
---|---|
_Bool | bool (определен в stdbool.h) |
unsigned char | - |
unsigned int | unsigned |
unsigned short | unsigned short int |
unsigned long | unsigned long int |
unsigned long long | unsigned long long int |
C99 вводит целочисленный тип _Bool для представления булевых значений. Истинное логическое значение кодируется как 1, а ложное как 0. Если включить в программу заголовочный файл stdbool.h
, можно использовать идентификаторы bool
, true
, false
.
Тип char
всегда занимает один байт. С определяет только минимальные размеры стандартных типов:
short
- имеет по крайней мере два байта.
long
- имеет по крайней четыре байта.
long long
по крайней мере восемь байт.
Хотя целочисленные типы могут быть больше указанных минимальных размеров, их размеры должны подчиняться следующему условию:
sizeof(short) <= sizeof(int) <= sizeof(long) <= sizeof(long long)
Типы с плавающей точкой
Тип | Синоним |
---|---|
float | - |
double | - |
long double |
Типы с плавающей точкой или вещественные(действительные) - это числа вида \(2.5, 4.1, {5\over2}\) которые могут содержать дробную часть. Хранятся данные числа в компьютере с ограниченной точностью
Для понимания ограниченной точности и также откуда появилось понятие “числа с плавающей точкой” нужно рассмотнреть пример того, как вещесвенные числа хранятся в памяти.
В качестве примера возьмем число 14.94 в десятичном формате и преобразуем его в двоичный.
Для преобразования нам нужно делить целую часть на 2, записывая остаток от деления, а дробную часть умножать на 2, записывая целую часть
1
2
3
4
14 / 2 = 7, остаток 0
7 / 2 = 3, остаток 1
3 / 2 = 1, остаток 1
1 / 2 = 0, остаток 1
После деления числа, в двоичном виде получаем \(1110_2\). Далее умножаем дробную часть
1
2
3
4
5
6
7
8
9
10
0.94 * 2 = 1.88, целая часть 1, остаток 0.88
0.88 * 2 = 1.76 целая часть 1, остаток 0.76
0.76 * 2 = 1.52, целая часть 1, остаток 0.52
0.52 * 2 = 1.04, целая часть 1, остаток 0.04
0.04 * 2 = 0.08, целая часть 0, остаток 0.08
0.08 * 2 = 0.16, целая часть 0, остаток 0.16
0.16 * 2 = 0.32, целая часть 0, остаток 0.32
0.32 * 2 = 0.64, целая часть 0, остаток 0.64
0.64 * 2 = 1.28, целая часть 1, остаток 0.28
0.28 * 2 = 0.56, целая часть 0, остаток 0.56
После умножения числа, в двоичном виде получаем \(1111000010_2\).
Умножение можно продолжать бесконечно, увеличивая тем самым точность числа (знаков после запятой), но в компьютерных системах используется конечное количество битов для представления дробной части числа. Когда вы представляете десятичную дробь в двоичной форме с конечным количеством бит, результат может быть только приблизительным. В реальности, длина представления ограничена, и точность зависит от числа бит, выделенных для представления дробной части.
\[14.94 = 1110.1111000010_2\]Для представления формата чисел с плавающей точкой существует стандарт IEEE 754, в котором описано, что двоичное число представляется в виде формулы: $$(-1)^s \times c \times b^q$$
Где \(s\) - знак числа, \(c\) - мантисса, \(b\) - основание, \(q\) - экспонента.
Так как мы работаем с двоичными числами, основание будет равно 2 и формула принимает вид: $$(-1)^s \times c \times 2^q$$
Чтобы привести двоичное число \(1110.1111000010_2\) к формуле, нужно сдвинуть запятую влево настолько, чтобы в целой части была единица, при этом каждый сдвиг увеличивает степень основания $$1.1101111000010_2$$
Количество сдвигов получилось равным 3, следовательно формула принимает вид: $$(-1)^0 \times 1.1101111000010_2 \times 2^3$$
Знак \(s\) равен нулю так как число положительное
Экспонента \(q\) равна \(3\), так как число имеет \(3\) цифры до запятой (мантисса нормализована)
Мантисса \(c\) равна \(1.1101111000010_2\) она нормализована ( то есть первая цифра всегда \(1\))
Нормализованный вид представления позволяет экономить бит памяти увеличивая точность представления числа. Передняя часть мантиссы всегда является \(1\) (при условии, что число не является нулем). Это позволяет не хранить этот ведущий бит в формате, так как он всегда известен и всегда равен \(1\).
Для завершения представления числа в памяти отсалось прибавить число \(127\) к экспоненте.
Процесс прибавления \(127\) к экспоненте связан с тем, что формат чисел с плавающей точкой IEEE 754 использует смещенную (biased) экспоненту. Смещение на \(127\) позволяет представить отрицательные экспоненты как положительные числа, что упрощает сравнение и операции с ними. $$ 3 + 127 = 130 (10000010_2) $$
Таким образом мы получаем представление нашего числа по формуле IEEE 754 $$0\ 10000010\ 11011111000010000000000_2 $$
Тип void
Тип void
указывает, что никакого значения нет.
Объявлять переменные такого типа нельзя, он предназначен для других операций.
Объявление указателей
Спомощью типа void
можно объявлять указатели. Такой указатель будет хранить адрес объекта в памяти, но не его тип. Объявление таких указателей дает некоторую универсальность при работе с памятью разных типов, например указатель на тип void
можно приводить к указателям на другие типы.
К примеру функции выделения динамической памяти возвращают указатель на void *
, который в дальнейшем может быть приведен к указателю нужного типа:
1
2
/*выделяем память для хранения 1024 целых типов*/
int *ptr = (int *)malloc(sizeof(int) * 1024);
Конструкция вида (int *)
выполняет приведение возвращаемого значения к типу указателя на int
.
Возвращаемое значение функций
При объявлении функции в качестве возвращаемого значения можно указать данный тип.
Такая функция не будет возвращать никаких значений:
1
2
3
void sayHello() {
printf("Hello");
}
Параметры функций
Если у функции нет параметров, данный тип также можно прописать в списке параметров функции:
1
2
3
void sayHello(void) {
printf("Hello");
}
Если попытаться вызвать такую функцию с каким-нибудь параметром, то компилятор выдаст ошибку:
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
void sayHello(void) {
printf("Hello");
}
int main() {
sayHello("Hello?");
return 0;
}
1
2
3
4
5
6
7
test.c: In function ‘main’:
test.c:9:9: error: too many arguments to function ‘sayHello’
9 | sayHello("Hello?");
| ^~~~~~~~
test.c:3:6: note: declared here
3 | void sayHello(void) {
| ^~~~~~~~
Если бы мы явно не указали void
в параметрах функции, то никаких ошибок мы бы не получили:
1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
void sayHello() {
printf("Hello");
}
int main() {
sayHello("Hello?");
return 0;
}
1
2
3
root@fedora-develop:~# gcc test.c
root@fedora-develop:~# ./a.out
Hello
Отбрасывание возвращаемых значений
1
(void) printf("Hello");
Такая конструкция используется редко, чаще всего возращаемое значение просто игнорируется, если его не нужно сохранять в переменной, достаточно просто написать вызов функции:
1
printf("Hello");
Размеры памяти и диапазоны значений типов
Таблица размеров типов и их диапазонов
Тип данных | Размер в байтах | Диапазон значений |
---|---|---|
char | 1 | от -128 до 127 или от 0 до 255 (в зависимости от знака) |
unsigned char | 1 | от 0 до 255 |
signed char | 1 | от -128 до 127 |
int | 2 или 4 | от -32 768 или -2 147 483 648 до 32 767 или 2 147 483 647 |
unsigned int | 2 или 4 | от 0 до 65 535 или 4 294 967 295 |
short | 2 | от -32 768 до 32 767 |
unsigned short | 2 | от 0 до 65 535 |
long | 4 | от -2 147 483 648 до 2 147 483 647 |
unsigned long | 4 | от 0 до 4 294 967 295 |
long long (C99) | 8 | от -9 223 372 036 854 775 808 до 9 223 372 036 854 775 807 |
unsigned long long (C99) | 8 | от 0 до 18 446 744 073 709 551 615 |
float | 4 | от ~1.2e-38 до ~3.4e38 (6 значащих цифр) |
double | 8 | от ~2.3e-308 до ~1.7e308 (15 значащих цифр) |
long double | 10 | от ~1.1e-4932 до ~3.4e-4932 (19 значащих цифр) |
Проверка размерности через оператор sizeof
Из таблицы видно что тип int
может занимать 2 или 4 байта памяти, но от чего это зависит?
Размер типа зависит от архитектуры и от компилятора, который используется для компиляции программы. Для того чтобы самостоятельно проверить какой объем занимает тот или иной тип, можно воспользоваться оператором sizeof
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
int main() {
printf("sizeof char\t\t\t = %lu bytes\n", sizeof(char));
printf("sizeof unsigned char\t\t = %lu bytes\n", sizeof(unsigned char));
printf("sizeof signed char\t\t = %lu bytes\n", sizeof(signed char));
printf("sizeof unsigned int\t\t = %lu bytes\n", sizeof(unsigned int));
printf("sizeof short\t\t\t = %lu bytes\n", sizeof(short));
printf("sizeof unsigned short\t\t = %lu bytes\n", sizeof(unsigned short));
printf("sizeof long\t\t\t = %lu bytes\n", sizeof(long));
printf("sizeof unsigned long\t\t = %lu bytes\n", sizeof(unsigned long));
printf("sizeof long long\t\t = %lu bytes\n", sizeof(long long));
printf("sizeof unsigned long long\t = %lu bytes\n", sizeof(unsigned long long));
printf("sizeof float\t\t\t = %lu bytes\n", sizeof(float));
printf("sizeof double\t\t\t = %lu bytes\n", sizeof(double));
printf("sizeof long double\t\t = %lu bytes\n", sizeof(long double));
return 0;
}
Вывод на моей системе получился следующий:
1
2
3
4
5
6
7
8
9
10
11
12
13
sizeof char = 1 bytes
sizeof unsigned char = 1 bytes
sizeof signed char = 1 bytes
sizeof unsigned int = 4 bytes
sizeof short = 2 bytes
sizeof unsigned short = 2 bytes
sizeof long = 8 bytes
sizeof unsigned long = 8 bytes
sizeof long long = 8 bytes
sizeof unsigned long long = 8 bytes
sizeof float = 4 bytes
sizeof double = 8 bytes
sizeof long double = 16 bytes