Если я определяю переменную определенного типа (которая, насколько я знаю, просто распределяет данные для содержимого переменной), как она отслеживает, какой это тип переменной?
Если я определяю переменную определенного типа (которая, насколько я знаю, просто распределяет данные для содержимого переменной), как она отслеживает, какой это тип переменной?
Ответы:
Переменные (или в более общем смысле: «объекты» в смысле 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
объект. Либо компилятор знает тип объекта во время компиляции, либо компилятор сохранил необходимую информацию о типе внутри объекта и может извлечь ее во время выполнения.
void*
).
typeid(e)
анализирует статический тип выражения e
. Если статический тип является полиморфным, выражение будет оценено, и будет получен динамический тип этого объекта. Вы не можете указать typeid на память неизвестного типа и получить полезную информацию. Например, typeid объединения описывает объединение, а не объект объединения. Typeid a void*
- это просто указатель на void. И невозможно разыменовать a, void*
чтобы получить его содержимое. В C ++ нет бокса, если он явно не запрограммирован.
Другой ответ хорошо объясняет технический аспект, но я хотел бы добавить некоторые общие «как думать о машинном коде».
Машинный код после компиляции довольно тупой, и он действительно предполагает, что все работает как задумано. Скажем, у вас есть простая функция, как
bool isEven(int i) { return i % 2 == 0; }
Он принимает int и выплевывает bool.
После того, как вы скомпилируете это, вы можете думать об этом как об этой автоматической апельсиновой соковыжималке:
Он принимает апельсины и возвращает сок. Распознает ли он тип объектов, в которые он попадает? Нет, они просто должны быть апельсинами. Что произойдет, если он получит яблоко вместо апельсина? Возможно, это сломается. Это не имеет значения, поскольку ответственный владелец не будет пытаться использовать его таким образом.
Вышеприведенная функция аналогична: она предназначена для приема целых чисел, и она может сломаться или сделать что-то неуместное, когда подается что-то другое. Это (обычно) не имеет значения, потому что компилятор (как правило) проверяет, что это никогда не происходит - и это действительно никогда не происходит в правильно сформированном коде. Если компилятор обнаруживает возможность того, что функция получит неправильное типизированное значение, он отказывается компилировать код и вместо этого возвращает ошибки типа.
Предостережение заключается в том, что в некоторых случаях неправильно сформированный код будет пропущен компилятором. Примеры:
void*
чтобы , orange*
когда есть яблоко на другом конце указателя,Как уже говорилось, скомпилированный код подобен машине соковыжималки - он не знает, что он обрабатывает, он просто выполняет инструкции. И если инструкции неверны, это нарушается. Вот почему вышеуказанные проблемы в C ++ приводят к неконтролируемым сбоям.
void*
принуждает foo*
, обычные арифметические продвижения, union
типизацию NULL
и т.д. nullptr
, даже если у вас плохой указатель - UB и т. Д. Но я не думаю, что перечисление всех этих вещей существенно улучшит ваш ответ, поэтому, вероятно, лучше оставить это как есть.
void*
не конвертируется неявно foo*
, и union
тип Punning не поддерживается (имеет UB).
Переменная имеет ряд фундаментальных свойств в таком языке, как C:
В вашем исходном коде местоположение (5) является концептуальным, и на это местоположение ссылается его название (1). Таким образом, объявление переменной используется для создания местоположения и пространства для значения (6), а в других строках источника мы ссылаемся на это местоположение и значение, которое оно содержит, называя переменную в некотором выражении.
Упрощенное только после того, как компилятор переведет вашу программу в машинный код, местоположение (5) - это место в памяти или регистре ЦП, а любые выражения исходного кода, ссылающиеся на переменную, преобразуются в последовательности машинного кода, ссылающиеся на эту память или регистр процессора.
Таким образом, когда перевод завершен и программа работает на процессоре, имена переменных фактически забываются в машинном коде, а инструкции, сгенерированные компилятором, относятся только к назначенным местоположениям переменных (а не к их имена). Если вы отлаживаете и запрашиваете отладку, местоположение переменной, связанной с именем, добавляется к метаданным для программы, хотя процессор все еще видит инструкции машинного кода, используя местоположения (а не эти метаданные). (Это чрезмерное упрощение, так как некоторые имена содержатся в метаданных программы для целей связывания, загрузки и динамического поиска - все же процессор просто выполняет инструкции машинного кода, которые ему сообщаются для программы, и в этом машинном коде имена имеют были преобразованы в местах.)
То же самое относится и к типу, области действия и времени жизни. Сгенерированные компилятором инструкции машинного кода знают машинную версию местоположения, в которой хранится значение. Другие свойства, например тип, скомпилированы в переведенный исходный код в виде специальных инструкций, которые обращаются к расположению переменной. Например, если рассматриваемая переменная представляет собой 8-разрядный байт со знаком против 8-разрядного байта без знака, то выражения в исходном коде, которые ссылаются на переменную, будут преобразованы, скажем, в загрузку байтов со знаком по сравнению с загрузкой байтов без знака, по мере необходимости, чтобы соответствовать правилам языка (C). Тип переменной, таким образом, кодируется в переводе исходного кода в машинные инструкции, которые командуют ЦПУ, как интерпретировать расположение памяти или регистров ЦП каждый раз, когда он использует местоположение переменной.
Суть в том, что мы должны сообщить процессору, что делать, с помощью инструкций (и других инструкций) в наборе команд машинного кода процессора. Процессор очень мало помнит о том, что он только что сделал или ему сказали - он только выполняет данные инструкции, и задача программиста на компиляторе или языке ассемблера состоит в том, чтобы дать ему полный набор последовательностей команд для правильной манипуляции с переменными.
Процессор напрямую поддерживает некоторые фундаментальные типы данных, такие как byte / word / int / long signature / unsigned, float, double и т. Д. Процессор, как правило, не будет жаловаться или возражать, если вы поочередно рассматриваете ту же область памяти как подписанную или unsigned Например, хотя это обычно будет логическая ошибка в программе. Задача программирования - инструктировать процессор при каждом взаимодействии с переменной.
Помимо этих фундаментальных примитивных типов, мы должны кодировать вещи в структурах данных и использовать алгоритмы для манипулирования ими в терминах этих примитивов.
В C ++ объекты, участвующие в иерархии классов для полиморфизма, имеют указатель, обычно в начале объекта, который ссылается на специфичную для класса структуру данных, которая помогает с виртуальной диспетчеризацией, приведением и т. Д.
Таким образом, процессор иначе не знает и не помнит предполагаемое использование мест хранения - он выполняет инструкции машинного кода программы, которые сообщают ему, как манипулировать памятью в регистрах ЦП и основной памяти. Программирование, таким образом, является задачей программного обеспечения (и программистов) осмысленно использовать хранилище и предоставлять согласованный набор инструкций машинного кода процессору, который добросовестно выполняет программу в целом.
useT1(&unionArray[i].member1); useT2(&unionArray[j].member2); useT1(&unionArray[i].member1);
, clang и gcc склонны считать, что указатель unionArray[j].member2
не может получить доступ, unionArray[i].member1
даже если оба они получены из одного и того же unionArray[]
.
если я определяю переменную определенного типа, как она отслеживает тип переменной, она есть.
Здесь есть два соответствующих этапа:
Компилятор C компилирует код C в машинный язык. Компилятор имеет всю информацию, которую он может получить из вашего исходного файла (и библиотек, и любых других вещей, которые ему нужны для своей работы). Компилятор C отслеживает, что означает что. Компилятор C знает, что если вы объявляете переменную char
, это char.
Это достигается с помощью так называемой «таблицы символов», в которой перечислены имена переменных, их тип и другая информация. Это довольно сложная структура данных, но вы можете думать о ней как о том, чтобы просто отслеживать, что означают читаемые человеком имена. В двоичном выводе компилятора имена переменных, подобные этому, больше не появляются (если мы игнорируем необязательную отладочную информацию, которая может быть запрошена программистом).
Выходные данные компилятора - скомпилированный исполняемый файл - это машинный язык, который загружается в оперативную память вашей ОС и исполняется непосредственно вашим процессором. В машинном языке вообще нет понятия «тип» - в нем есть только команды, которые работают в некотором месте в ОЗУ. Эти команды действительно имеют фиксированного типа они работают с (то есть, может быть команда машинного языка «добавить эти два 16-разрядных целых чисел , хранящихся в местах RAM 0x100 и 0x521»), но нет никакой информации в любом месте в системе , что байты в этих местах на самом деле представляют целые числа. Там нет никакой защиты от ошибок типа вообще здесь.
char *ptr = 0x123
в C). Я считаю, что мое использование слова «указатель» должно быть достаточно ясным в этом контексте. Если нет, не стесняйтесь, дайте мне знать, и я добавлю предложение к ответу.
Есть несколько важных особых случаев, когда C ++ сохраняет тип во время выполнения.
Классическим решением является дискриминационное объединение: структура данных, которая содержит один из нескольких типов объектов, плюс поле, в котором указано, какой тип он содержит в настоящее время. Шаблонная версия находится в стандартной библиотеке C ++ как std::variant
. Обычно это тег enum
, но если вам не нужны все биты для хранения ваших данных, это может быть битовое поле.
Другой распространенный случай - динамическая типизация. Когда у вас class
есть virtual
функция, программа будет хранить указатель на эту функцию в таблице виртуальных функций , которую она будет инициализировать для каждого экземпляра, class
когда она создается . Обычно это будет означать одну таблицу виртуальных функций для всех экземпляров класса, и каждый экземпляр будет содержать указатель на соответствующую таблицу. (Это экономит время и память, поскольку таблица будет намного больше одного указателя.) Когда вы вызываете эту virtual
функцию через указатель или ссылку, программа будет искать указатель функции в виртуальной таблице. (Если он знает точный тип во время компиляции, он может пропустить этот шаг.) Это позволяет коду вызывать реализацию производного типа вместо базового класса.
Здесь это важно: каждый ofstream
содержит указатель на ofstream
виртуальную таблицу, каждый ifstream
на ifstream
виртуальную таблицу и так далее. Для иерархий классов указатель виртуальной таблицы может служить тегом, который сообщает программе, какой тип имеет объект класса!
Хотя языковой стандарт не говорит людям, которые проектируют компиляторы, как они должны реализовывать среду выполнения под капотом, это то, как вы можете ожидать dynamic_cast
и typeof
работать.