Что такое типы данных в любом случае?

Перевод | Автор оригинала: Matt Oswalt

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

В результате типы данных (по крайней мере, общие / простые) представляют собой концепцию, которую подавляющее большинство из нас - даже программисты с минимальным опытом - способны понять, что принимают это как должное. Программисты, освоившие современные технологии, могут быть удовлетворены тем, что строки предназначены для «серии символов», а целые числа - для «чисел», и им не нужно задаваться вопросом, что это на самом деле означает. И, честно говоря, я не уверен, что есть что-то плохое в том, чтобы в некоторых случаях оставить все как есть - множество совершенно функциональных программ было создано с помощью простого использования языковых инструментов, не задавая вопросов.

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

Правда о типах

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

Типы - это абстракция смещения памяти.

Вот и все. Цель наличия таких типов, как целые числа, строки и числа с плавающей запятой, а также предоставления примитивов для определения ваших собственных типов (например, структур) состоит в том, чтобы компилятор знал, сколько памяти выделить для данного фрагмента данных. Это абстракция, которая полезна только во время компиляции и существует для того, чтобы программисту было проще рассуждать об этих выделениях, не осознавая этого.

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

Простой пример (встроенные целочисленные типы)

Выше я назвал типы «абстракцией смещения памяти». А пока давайте сосредоточимся на термине «смещение памяти» для тех, кто может быть не знаком. В моем предыдущем посте, исследуя дизассемблированные инструкции простой программы, мы могли видеть, что определенные инструкции были предоставлены с заданным байтовым смещением, что указывало на количество байтов от 0, в котором эта инструкция была расположена в файле.

Точно так же для читателей, имеющих опыт работы в сети, протоколы, которые мы знаем и любим, такие как Интернет-протокол (IP), имеют стандартизованные форматы заголовков по той же причине. В заголовке Интернет-протокола (IP) нам не нужен какой-то «специальный сигнал», чтобы маршрутизатор знал, когда он прочитал полное поле адреса источника - мы указали в стандарте, что это поле имеет длину 32 бита. Итак, когда мы получаем этот 32-й бит, мы знаем, что поле закончилось, и мы начинаем получать следующее.

В общем, причина, по которой нам нравится работать с хорошо известными смещениями памяти, заключается в том, что это упрощает написание программного обеспечения. Проблема в том, что в современных приложениях может быть очень утомительно работать только с этими смещениями памяти - поэтому большинство языков предлагают «типы данных» в качестве абстракции, чтобы программисту не приходилось постоянно думать о том, сколько байтов задано. type должен быть выделен - компилятор может сделать эту работу за нас.

Например, следующий пример Rust создает переменную x и присваивает ей значение 5. Хотя Rust автоматически предполагает, что это 32-битное целое число, мы собираемся указать это явно для иллюстрации, поэтому мы добавим приведение типа к i32:

fn main() {
    let x: i32 = 5;
}

Если вы помните из моего предыдущего поста, мы можем изучить скомпилированный машинный код с помощью инструмента objdump. Я использую здесь почти то же самое, но я добавляю флаг --disassemble = rbin::main, чтобы я мог перейти непосредственно к соответствующему коду для этого примера. Я также использую флаг -S, который чередует исходный код Rust внутри машинного кода, чтобы мы могли более четко увидеть, как Rust и машинный код связаны друг с другом:

~$ objdump --disassemble=rbin::main -S -C target/debug/rbin -EL -M intel --insn-width=8
target/debug/rbin:     file format elf64-x86-64

...

0000000000005310 <rbin::main>:
fn main() {
    5310:	48 83 ec 04             	sub    rsp,0x4
    let x: i32 = 5;
    5314:	c7 04 24 05 00 00 00    	mov    DWORD PTR [rsp],0x5
}
    531b:	48 83 c4 04             	add    rsp,0x4
    531f:	c3                      	ret    

Разборка раздела .fini:

Обратите особое внимание на вывод, там есть и Rust, и машинный код из-за флага -S. Все, что следует за форматом из четырех столбцов (смещение, байты, инструкция, параметры), является машинным кодом, остальное - Rust (игнорируя любой вывод из самого objdump). Только машинный код попадает в итоговую скомпилированную программу, остальное предоставляется objdump, чтобы помочь нам разобраться в вещах.

Первая инструкция выделяет четыре байта пространства стека для основной функции:

sub    rsp,0x4

Почему компилятор выделяет 4 байта? Оказывается, именно столько требуется для 32-битного целого числа (4 байта x 8 = 32), и перемещение значения в это пространство памяти - единственная операция, выполняемая в этой функции, так что это все пространство нам нужно.

Затем мы можем переместить шестнадцатеричный эквивалент 5 в это пространство памяти:

mov    DWORD PTR [rsp],0x5

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

Этот пример был слишком простым, поэтому давайте добавим еще несколько присваиваний (y и z), чтобы подчеркнуть важность смещений памяти:

0000000000005310 <rbin::main>:
fn main() {
    5310:	48 83 ec 0c             	sub    rsp,0xc
    let x: i32 = 5;
    5314:	c7 04 24 05 00 00 00    	mov    DWORD PTR [rsp],0x5
    let y: i32 = 255;
    531b:	c7 44 24 04 ff 00 00 00 	mov    DWORD PTR [rsp+0x4],0xff
    let z: i32 = 0;
    5323:	c7 44 24 08 00 00 00 00 	mov    DWORD PTR [rsp+0x8],0x0

}
    532b:	48 83 c4 0c             	add    rsp,0xc
    532f:	c3                      	ret    

Этот пример немного проясняет ситуацию. Размер нашего стека теперь намного превышает 4 байта; 0xc, преобразованный из шестнадцатеричного числа, равен 12. Опять же, это распределение выполняется в результате вычислений компилятором. Он знает, что мы присваиваем 3 переменным, каждая из которых является 32-битным целым числом. Поскольку каждому требуется 4 байта, нам потребуется всего 12 байтов для выполнения следующих операций:

5310:	48 83 ec 0c             	sub    rsp,0xc

Наше присвоение x такое же, как и раньше, но обратите внимание, что машинный код для присвоения y имеет некоторый дополнительный синтаксис:

531b:	c7 44 24 04 ff 00 00 00 	mov    DWORD PTR [rsp+0x4],0xff

Значение 0xff является шестнадцатеричным для 255, так что это фактические данные, которые мы храним, но расположение памяти предоставляется как смещение памяти от указателя стека rsp, а именно четыре байта (rsp + 0x4). На самом деле это гораздо меньше связано с y и больше связано с x. Типу, который мы используем для x, требуется четыре байта памяти, поэтому начальная ячейка памяти, которая должна использоваться для y, - это адрес, который представляет собой смещение на четыре байта от адреса, используемого для хранения этого значения, которое в данном случае было местоположением обозначается rsp.

Смущенный? Попробуйте вместо этого использовать 16-битные целые числа:

0000000000005310 <rbin::main>:
fn main() {
    5310:	48 83 ec 06             	sub    rsp,0x6
    let x: i16 = 5;
    5314:	66 c7 04 24 05 00       	mov    WORD PTR [rsp],0x5
    let y: i16 = 255;
    531a:	66 c7 44 24 02 ff 00    	mov    WORD PTR [rsp+0x2],0xff
    let z: i16 = 0;
    5321:	66 c7 44 24 04 00 00    	mov    WORD PTR [rsp+0x4],0x0
}
    5328:	48 83 c4 06             	add    rsp,0x6
    532c:	c3                      	ret    

Распределение нашего стека теперь намного меньше, и байтовые смещения для каждого также уменьшены вдвое. y нужно хранить только на 2 байта перед x, так как x занимает только столько места. Между прочим, y имеет тот же размер, поэтому z может идти на 2 байта впереди (rsp плюс четыре байта).

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

Пользовательские типы, массивы и Vecs

Оказывается, структуры на самом деле не такие уж и разные. Они представляют собой набор смещений памяти. Давайте создадим структуру Point с двумя целочисленными полями и создадим ее экземпляр:

fn main() {
    let p = Point { x: 5, y: 2 };
}

struct Point {
    x: i32,
    y: i32,
}

Когда дело доходит до базового машинного кода, каждое поле состоит из четырех байтов, поэтому первое поле сохраняется в местоположении rsp, а второе поле - через четыре байта после этого:

0000000000005310 <rbin::main>:
fn main() {
    5310:	50                      	push   rax
    let p = Point { x: 5, y: 2 };
    5311:	c7 04 24 05 00 00 00    	mov    DWORD PTR [rsp],0x5
    5318:	c7 44 24 04 02 00 00 00 	mov    DWORD PTR [rsp+0x4],0x2
}
    5320:	58                      	pop    rax
    5321:	c3                      	ret    

Массивы также следуют той же формуле:

0000000000005310 <rbin::main>:
fn main() {
    5310:       50                              push   rax
    let ints = [5, 2];
    5311:       c7 04 24 05 00 00 00            mov    DWORD PTR [rsp],0x5
    5318:       c7 44 24 04 02 00 00 00         mov    DWORD PTR [rsp+0x4],0x2
}
    5320:       58                              pop    rax
    5321:       c3                              ret

Здесь будет интересно попробовать использовать Vec вместо массива. Вы заметите, что все становится намного сложнее:

0000000000006330 <rbin::main>:
fn main() {
    6330:       48 83 ec 18                     sub    rsp,0x18
    let ints = vec![5, 2];
    6334:       bf 08 00 00 00                  mov    edi,0x8
    6339:       be 04 00 00 00                  mov    esi,0x4
    633e:       e8 dd f1 ff ff                  call   5520 <alloc::alloc::exchange_malloc>
    6343:       48 89 c1                        mov    rcx,rax
    6346:       c7 00 05 00 00 00               mov    DWORD PTR [rax],0x5
    634c:       c7 40 04 02 00 00 00            mov    DWORD PTR [rax+0x4],0x2
    6353:       48 89 e7                        mov    rdi,rsp
    6356:       48 89 ce                        mov    rsi,rcx
    6359:       ba 02 00 00 00                  mov    edx,0x2
    635e:       e8 dd fe ff ff                  call   6240 <alloc::slice::<impl [T]>::into_vec>
}
    6363:       48 89 e7                        mov    rdi,rsp
    6366:       e8 15 fc ff ff                  call   5f80 <core::ptr::drop_in_place>
    636b:       48 83 c4 18                     add    rsp,0x18
    636f:       c3                              ret   

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

Вывод

Несколько напутственных мыслей: