Как переменные в C ++ хранят свой тип?

42

Если я определяю переменную определенного типа (которая, насколько я знаю, просто распределяет данные для содержимого переменной), как она отслеживает, какой это тип переменной?

Финн МакКласки
источник
8
На кого / что вы ссылаетесь, используя « это » в « как оно отслеживает »? Компилятор или процессор или что-то еще, как язык или программа?
Эрик Эйдт
8
@ErikEidt IMO OP означает «сама переменная» под «ней». Конечно, ответ на этот вопрос состоит из двух слов: «Это не так».
Алефзеро
2
отличный вопрос! особенно актуально сегодня, учитывая все модные языки, которые хранят их тип.
Тревор Бойд Смит
@alephzero Это был очевидно главный вопрос.
Луаан

Ответы:

105

Переменные (или в более общем смысле: «объекты» в смысле C) не сохраняют свой тип во время выполнения. Что касается машинного кода, то здесь есть только нетипизированная память. Вместо этого операции над этими данными интерпретируют данные как определенный тип (например, как число с плавающей точкой или как указатель). Типы используются только компилятором.

Например, у нас может быть структура или класс struct Foo { int x; float y; };и переменная Foo f {}. Как можно получить доступ к полю auto result = f.y;? Компилятор знает, что fэто объект типа, Fooи знает расположение Foo-объектов. В зависимости от конкретной платформы, это может быть скомпилировано как «Возьмите указатель на начало f, добавьте 4 байта, затем загрузите 4 байта и интерпретируйте эти данные как число с плавающей запятой». Во многих наборах команд машинного кода (включая x86-64 ) Существуют разные инструкции процессора для загрузки чисел с плавающей запятой.

Одним из примеров, когда система типов C ++ не может отслеживать тип для нас, является подобное объединение union Bar { int as_int; float as_float; }. Объединение содержит до одного объекта различных типов. Если мы сохраняем объект в объединении, это активный тип объединения. Мы должны только попытаться вернуть этот тип из объединения, все остальное будет неопределенным поведением. Либо мы «знаем» при программировании, что такое активный тип, либо мы можем создать теговое объединение, где мы храним тег типа (обычно перечисление) отдельно. Это обычная техника в C, но поскольку мы должны синхронизировать объединение и тег типа, это довольно подвержено ошибкам. void*Указатель похож на союз , но может содержать только объекты указателей, кроме указателей на функции.
C ++ предлагает два лучших механизма для работы с объектами неизвестных типов: мы можем использовать объектно-ориентированные методы для удаления типов (взаимодействовать с объектом только через виртуальные методы, чтобы нам не нужно было знать фактический тип), или мы можем использовать std::variant, своего рода тип безопасного союза.

В одном случае C ++ сохраняет тип объекта: если у класса объекта есть какие-либо виртуальные методы («полиморфный тип», он же интерфейс). Цель вызова виртуального метода неизвестна во время компиляции и разрешается во время выполнения на основе динамического типа объекта («динамическая диспетчеризация»). Большинство компиляторов реализуют это, сохраняя таблицу виртуальных функций («vtable») в начале объекта. Vtable также может быть использован для получения типа объекта во время выполнения. Затем мы можем провести различие между известным статическим типом выражения во время компиляции и динамическим типом объекта во время выполнения.

C ++ позволяет нам проверять динамический тип объекта с помощью typeid()оператора, который дает нам std::type_infoобъект. Либо компилятор знает тип объекта во время компиляции, либо компилятор сохранил необходимую информацию о типе внутри объекта и может извлечь ее во время выполнения.

Амон
источник
3
Очень всеобъемлющий
Дедупликатор
9
Обратите внимание, что для доступа к типу полиморфного объекта компилятор все же должен знать, что объект принадлежит к определенному семейству наследования (т.е. иметь типизированную ссылку / указатель на объект, а не void*).
Руслан
5
+0 потому что первое предложение не соответствует действительности, а два последних абзаца исправляют его.
Марчин
3
Обычно в начале полиморфного объекта хранится указатель на таблицу виртуальных методов, а не сама таблица.
Питер Грин
3
@ v.oddou В своем абзаце я проигнорировал некоторые детали. typeid(e)анализирует статический тип выражения e. Если статический тип является полиморфным, выражение будет оценено, и будет получен динамический тип этого объекта. Вы не можете указать typeid на память неизвестного типа и получить полезную информацию. Например, typeid объединения описывает объединение, а не объект объединения. Typeid a void*- это просто указатель на void. И невозможно разыменовать a, void*чтобы получить его содержимое. В C ++ нет бокса, если он явно не запрограммирован.
Амон
51

Другой ответ хорошо объясняет технический аспект, но я хотел бы добавить некоторые общие «как думать о машинном коде».

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

bool isEven(int i) { return i % 2 == 0; }

Он принимает int и выплевывает bool.

После того, как вы скомпилируете это, вы можете думать об этом как об этой автоматической апельсиновой соковыжималке:

автоматическая соковыжималка для апельсина

Он принимает апельсины и возвращает сок. Распознает ли он тип объектов, в которые он попадает? Нет, они просто должны быть апельсинами. Что произойдет, если он получит яблоко вместо апельсина? Возможно, это сломается. Это не имеет значения, поскольку ответственный владелец не будет пытаться использовать его таким образом.

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

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

  • неправильный тип литья: явные слепки считаются правильными, и именно на программиста , чтобы гарантировать , что он не кастинг , void*чтобы , orange*когда есть яблоко на другом конце указателя,
  • проблемы управления памятью, такие как нулевые указатели, висячие указатели или использование после области; компилятор не может найти большинство из них,
  • Я уверен, что что-то еще мне не хватает.

Как уже говорилось, скомпилированный код подобен машине соковыжималки - он не знает, что он обрабатывает, он просто выполняет инструкции. И если инструкции неверны, это нарушается. Вот почему вышеуказанные проблемы в C ++ приводят к неконтролируемым сбоям.

Frax
источник
4
Компилятор пытается проверить, что функции передан объект правильного типа, но и C, и C ++ слишком сложны, чтобы компилятор мог доказать это в каждом случае. Итак, сравнение яблок и апельсинов с соковыжималкой весьма поучительно.
Calchas
@Calchas Спасибо за ваш комментарий! Это предложение действительно было упрощением. Я немного уточнил возможные проблемы, на самом деле они довольно тесно связаны с вопросом.
Frax
5
вау отличная метафора для машинного кода! Ваша метафора также улучшена в 10 раз благодаря картинке!
Тревор Бойд Смит
2
«Я уверен, что есть кое-что еще, что я скучаю». - Конечно! С void*принуждает foo*, обычные арифметические продвижения, unionтипизацию NULLи т.д. nullptr, даже если у вас плохой указатель - UB и т. Д. Но я не думаю, что перечисление всех этих вещей существенно улучшит ваш ответ, поэтому, вероятно, лучше оставить это как есть.
Кевин
@Kevin Я не думаю, что здесь нужно добавлять C, поскольку вопрос только помечен как C ++. И в C ++ void*не конвертируется неявно foo*, и unionтип Punning не поддерживается (имеет UB).
Руслан
3

Переменная имеет ряд фундаментальных свойств в таком языке, как C:

  1. Имя
  2. Тип
  3. Сфера
  4. Продолжительность жизни
  5. Местоположение
  6. Ценность

В вашем исходном коде местоположение (5) является концептуальным, и на это местоположение ссылается его название (1). Таким образом, объявление переменной используется для создания местоположения и пространства для значения (6), а в других строках источника мы ссылаемся на это местоположение и значение, которое оно содержит, называя переменную в некотором выражении.

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

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

То же самое относится и к типу, области действия и времени жизни. Сгенерированные компилятором инструкции машинного кода знают машинную версию местоположения, в которой хранится значение. Другие свойства, например тип, скомпилированы в переведенный исходный код в виде специальных инструкций, которые обращаются к расположению переменной. Например, если рассматриваемая переменная представляет собой 8-разрядный байт со знаком против 8-разрядного байта без знака, то выражения в исходном коде, которые ссылаются на переменную, будут преобразованы, скажем, в загрузку байтов со знаком по сравнению с загрузкой байтов без знака, по мере необходимости, чтобы соответствовать правилам языка (C). Тип переменной, таким образом, кодируется в переводе исходного кода в машинные инструкции, которые командуют ЦПУ, как интерпретировать расположение памяти или регистров ЦП каждый раз, когда он использует местоположение переменной.

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

Процессор напрямую поддерживает некоторые фундаментальные типы данных, такие как byte / word / int / long signature / unsigned, float, double и т. Д. Процессор, как правило, не будет жаловаться или возражать, если вы поочередно рассматриваете ту же область памяти как подписанную или unsigned Например, хотя это обычно будет логическая ошибка в программе. Задача программирования - инструктировать процессор при каждом взаимодействии с переменной.

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

В C ++ объекты, участвующие в иерархии классов для полиморфизма, имеют указатель, обычно в начале объекта, который ссылается на специфичную для класса структуру данных, которая помогает с виртуальной диспетчеризацией, приведением и т. Д.

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

Эрик Эйдт
источник
1
Осторожнее с «когда перевод завершен, имя забыто» ... связывание осуществляется через имена («неопределенный символ xy») и вполне может происходить во время выполнения с динамическим связыванием. См. Blog.fesnel.com/blog/2009/08/19/… . Никаких отладочных символов, даже разделенных: вам нужно имя функции (и, я полагаю, глобальной переменной) для динамического связывания. Таким образом, только имена внутренних объектов могут быть забыты. Кстати, хороший список переменных свойств.
Питер - Восстановить Монику
@ PeterA.Schneider, вы абсолютно правы, в целом, в том, что линкеры и загрузчики также участвуют и используют имена (глобальных) функций и переменных, которые взяты из исходного кода.
Эрик Эйдт
Дополнительным осложнением является то, что некоторые компиляторы интерпретируют правила, которые, согласно Стандарту, позволяют компиляторам предполагать, что определенные вещи не будут псевдонимами, что позволяет им рассматривать операции с различными типами как непоследовательные, даже в тех случаях, когда псевдонимы не связаны с написанием . Учитывая что-то вроде useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);, clang и gcc склонны считать, что указатель unionArray[j].member2не может получить доступ, unionArray[i].member1даже если оба они получены из одного и того же unionArray[].
суперкат
Независимо от того, правильно ли компилятор интерпретирует спецификацию языка, его задача - генерировать последовательности команд машинного кода, которые выполняют программу. Это означает, что (оптимизация по модулю и многие другие факторы) для каждой переменной доступа в исходном коде необходимо сгенерировать некоторые инструкции машинного кода, которые сообщают процессору, какой размер и интерпретацию данных использовать для места хранения. Процессор ничего не помнит о переменной, поэтому каждый раз, когда он должен обращаться к переменной, он должен быть точно проинструктирован, как это сделать.
Эрик Эйдт
2

если я определяю переменную определенного типа, как она отслеживает тип переменной, она есть.

Здесь есть два соответствующих этапа:

  • Время компиляции

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

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

  • время выполнения

Выходные данные компилятора - скомпилированный исполняемый файл - это машинный язык, который загружается в оперативную память вашей ОС и исполняется непосредственно вашим процессором. В машинном языке вообще нет понятия «тип» - в нем есть только команды, которые работают в некотором месте в ОЗУ. Эти команды действительно имеют фиксированного типа они работают с (то есть, может быть команда машинного языка «добавить эти два 16-разрядных целых чисел , хранящихся в местах RAM 0x100 и 0x521»), но нет никакой информации в любом месте в системе , что байты в этих местах на самом деле представляют целые числа. Там нет никакой защиты от ошибок типа вообще здесь.

Anoe
источник
Если по какой-либо причине вы ссылаетесь на C # или Java с «языками, ориентированными на байт-код», то указатели от них ни в коем случае не опускаются; Напротив, указатели гораздо чаще встречаются в C # и Java (и, следовательно, одной из наиболее распространенных ошибок в Java является «NullPointerException»). То, что они называются «ссылками», является лишь вопросом терминологии.
Питер - Восстановить Монику
@ PeterA.Schneider, конечно, есть исключение NullPOINTERException, но есть четкое различие между ссылкой и указателем в языках, которые я упомянул (например, Java, ruby, вероятно, C #, даже Perl в некоторой степени) - ссылки идут вместе с их системой типов, сборкой мусора, автоматическим управлением памятью и т. д .; обычно даже невозможно явно указать место в памяти (как char *ptr = 0x123в C). Я считаю, что мое использование слова «указатель» должно быть достаточно ясным в этом контексте. Если нет, не стесняйтесь, дайте мне знать, и я добавлю предложение к ответу.
АНОЭ
указатели «идут вместе с системой типов» в C ++ ;-). (На самом деле классические дженерики Java имеют менее строгую типизацию, чем С ++.) Сборка мусора - это функция, которую С ++ решил не предписывать, но возможна для реализации, и она не имеет ничего общего с тем, какое слово мы используем для указателей.
Питер - Восстановить Монику
Хорошо, @ PeterA.Schneider, я действительно не думаю, что мы получаем уровень здесь. Я удалил абзац, в котором упомянул указатели, так или иначе он ничего не сделал для ответа.
AnoE
1

Есть несколько важных особых случаев, когда C ++ сохраняет тип во время выполнения.

Классическим решением является дискриминационное объединение: структура данных, которая содержит один из нескольких типов объектов, плюс поле, в котором указано, какой тип он содержит в настоящее время. Шаблонная версия находится в стандартной библиотеке C ++ как std::variant. Обычно это тег enum, но если вам не нужны все биты для хранения ваших данных, это может быть битовое поле.

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

Здесь это важно: каждый ofstreamсодержит указатель на ofstreamвиртуальную таблицу, каждый ifstreamна ifstreamвиртуальную таблицу и так далее. Для иерархий классов указатель виртуальной таблицы может служить тегом, который сообщает программе, какой тип имеет объект класса!

Хотя языковой стандарт не говорит людям, которые проектируют компиляторы, как они должны реализовывать среду выполнения под капотом, это то, как вы можете ожидать dynamic_castи typeofработать.

Davislor
источник
«Стандарт языка не говорит кодерам», вы, вероятно, должны подчеркнуть, что «кодерами», о которых идет речь, являются люди, пишущие gcc, clang, msvc и т. д., а не люди, использующие их для компиляции своего C ++.
Caleth
@Caleth Хорошее предложение!
Дэвислор