Что такое типы данных в любом случае?
Перевод | Автор оригинала: Matt Oswalt
На самом деле существует довольно много ресурсов для начинающего программиста, чтобы узнать о таких типах данных, как строки, целые числа, числа с плавающей запятой и т.д. Страница Википедии, например, охватывает широкий спектр возможных значений. Практически любая книга или учебное пособие, посвященное конкретному языку программирования, начинается с перечисления типов, поддерживаемых этим языком. В этом есть смысл, поскольку они являются фундаментальным строительным блоком, позволяющим делать практически все на этом языке. Более того, как только вы изучите типы на одном языке, подавляющее большинство будет поддерживаться и на любом другом языке, а в худшем случае будет немного другое имя или синтаксис.
В результате типы данных (по крайней мере, общие / простые) представляют собой концепцию, которую подавляющее большинство из нас - даже программисты с минимальным опытом - способны понять, что принимают это как должное. Программисты, освоившие современные технологии, могут быть удовлетворены тем, что строки предназначены для «серии символов», а целые числа - для «чисел», и им не нужно задаваться вопросом, что это на самом деле означает. И, честно говоря, я не уверен, что есть что-то плохое в том, чтобы в некоторых случаях оставить все как есть - множество совершенно функциональных программ было создано с помощью простого использования языковых инструментов, не задавая вопросов.
Однако, стремясь погрузиться глубже, я стараюсь задавать эти вопросы всякий раз, когда могу, и в данном случае я думаю, что важно выявить фундаментальную истину, которая может быть не столь очевидна для тех, кто не прошел традиционную практику. образование в области информатики.
Правда о типах
Поэтому, прежде чем я закопаю леде, я сразу скажу об этом, настолько лаконично, насколько мне хотелось бы, чтобы это было сказано мне на раннем этапе:
Типы - это абстракция смещения памяти.
Вот и все. Цель наличия таких типов, как целые числа, строки и числа с плавающей запятой, а также предоставления примитивов для определения ваших собственных типов (например, структур) состоит в том, чтобы компилятор знал, сколько памяти выделить для данного фрагмента данных. Это абстракция, которая полезна только во время компиляции и существует для того, чтобы программисту было проще рассуждать об этих выделениях, не осознавая этого.
В следующих разделах мы рассмотрим несколько примеров, иллюстрирующих эту истину. Прежде чем мы туда доберемся, у меня есть два заявления об отказе от ответственности:
-
Я сосредоточусь на типах, поскольку они относятся к процедурному программированию и связанным с ними концепциям. Объектно-ориентированные языки добавляют дополнительную абстракцию к тому, что мы здесь обсудим. На самом деле есть и другие подобные кроличьи норы, в которые я мог бы нырнуть, но мы будем держаться подальше от всего этого во имя простоты.
-
Я буду использовать Rust, чтобы проиллюстрировать некоторые примеры, но имейте в виду, что каждый язык имеет не только свой собственный синтаксис для встроенных и определяемых пользователем типов, но также может отличаться способом, которым эти типы проявляются в машинном коде. Также я буду использовать параметры отладки по умолчанию для Cargo, поэтому ваш код может компилироваться по-разному в зависимости от выбранных вами параметров. Специфика не так важна, как приобретение навыков «заглядывать за занавес».
Простой пример (встроенные целочисленные типы)
Выше я назвал типы «абстракцией смещения памяти». А пока давайте сосредоточимся на термине «смещение памяти» для тех, кто может быть не знаком. В моем предыдущем посте, исследуя дизассемблированные инструкции простой программы, мы могли видеть, что определенные инструкции были предоставлены с заданным байтовым смещением, что указывало на количество байтов от 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 плюс четыре байта).
Это был простой пример, но детали здесь очень важны, если вы хотите понять, какая часть современного программного обеспечения действительно работает. Мы продолжим развивать это, но прежде чем продолжить, я хотел бы обратить внимание на два конкретных вывода:
-
Нам, программистам на Rust, не приходилось самостоятельно указывать смещение памяти. Мы указали тип i32, который является псевдонимом для 32-разрядного смещения памяти, которое требуется для этого типа, и компилятор позаботился о выделении памяти для нас и перемещении желаемых значений в правильные ячейки памяти, вычисляя необходимое смещение на основе размер, необходимый для каждого типа. Другие языки могут использовать даже более простые имена, такие как int, но обычно они имеют смещение памяти по умолчанию, и вам важно знать, что это такое.
-
Также обратите внимание, что в машинном коде нет упоминания об i32 (в качестве пояснения мы видели это в выводе objdump, но помните, что это был просто хороший результат сравнения, предоставленный инструментом, поэтому мы знали, откуда пришел машинный код. из предварительной компиляции - в результирующем двоичном файле на самом деле не было никакого кода Rust). Типы - это инструмент компилятора, облегчающий жизнь программисту; программист использует эти типы, а компилятор интерпретирует их использование, чтобы знать, сколько памяти выделить и где разместить значения. Как только он это сделает, эта абстракция отпадет.
Пользовательские типы, массивы и 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
-
Вы могли заметить в выходных данных выше, что первая инструкция - это push rax, а не инструкция выделения стека, которую мы видели (например, sub rsp,
). В моем первоначальном исследовании выяснилось, что это делается для целей выравнивания стека, но после этого нет инструкции по вызову, поэтому я не думаю, что здесь происходит именно это. -
Вместо этого я считаю, что это всего лишь ярлык, сделанный компилятором Rust для более эффективного выделения 8 байтов в стеке. Это размер нашей структуры, поэтому это имеет смысл - добавление или удаление полей приводит к подвызову. Когда вы это видите, для примера вы можете рассматривать это как эквивалент sub rsp, 0x8. Вы также заметите, что в конце следует вызов pop rax, который возвращает это пространство обратно в стек.
Массивы также следуют той же формуле:
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 выделяется в куче, что требует дополнительного вызова и, следовательно, дополнительной сложности. Это ожидаемо, но следует отметить, что, несмотря на метод распределения, структура памяти для выделяемых типов идентична.
Вывод
Несколько напутственных мыслей:
-
Это был простой, наглядный пример. Детали того, что вы читаете выше, не так важны, как выработка привычки копаться в машинном коде, чтобы увидеть, что на самом деле делает этот высокоуровневый код, который вы пишете. Это хорошая привычка, которую я пытаюсь усвоить, и я бы посоветовал вам поступить так же.
-
Большая часть того, что мы рассматривали, было простыми значениями, распределенными в стеке. При выделении кучи памяти для данных процесс получения этой памяти может отличаться, но вам все равно необходимо знать форму ваших данных независимо от того, где они хранятся.
-
Крейт memoffset полезен для получения смещения определенных типов с помощью макросов времени компиляции. Я встречал несколько случаев использования (особенно в графическом программировании), когда размер данного типа должен быть известен до его использования в определенных API, и это полезно для этого.