Каламбур типизации
Каламбур типизации (англ. type punning) — термин, который используется в информатике для обозначения различных техник нарушения или обмана системы типов некоторого языка программирования, имеющих эффект, который было бы затруднительно или невозможно обеспечить в рамках формального языка.
Языки Си и C++ предоставляют явные возможности каламбура типизации посредством таких конструкций, как приведение типов, union
, а также reinterpret_cast
для C++, хотя стандарты этих языков некоторые случаи таких каламбуров трактуют, как неопределённое поведение.
В языке Pascal записи с вариантами могут использоваться для интерпретации конкретного типа данных более, чем одним способом, или даже не предусмотренным языком способом.
Каламбур типизации является прямым нарушением типобезопасности. Традиционно возможность построить каламбур типизации связывается со слабой типизацией, но и некоторые сильно типизированные языки или их реализации предоставляют такие возможности (как правило, используя в связанных с ними идентификаторах слова unsafe
или unchecked
). Сторонники типобезопасности утверждают, что «необходимость» каламбуров типизации является мифом[1].
Примеры
Строки и числа в JavaScript
JS позволяет неявное приведение типов между строками и числами, что может приводить к нелогичным результатам, например:
console.log(2 + 2) // 4
console.log("2" + "2") // "22"
console.log(2 + 2 - 2) // 2
console.log("2" + "2" - "2") // "20"
Оператор +
для чисел работает как сложение, а для строк как конкатенация, однако оператор -
работает только как вычитание для чисел, поэтому в последнем выражении мы получаем "22" - "2"
что приводит к значению 20
.
Сравнение в JavaScript
Сравнение между значениями разных типов в JS не транзитивно:
0 == "0"
0 == []
"0" != []
Сокеты в Си
Классический пример каламбура типизации можно видеть в интерфейсе сокетов Беркли. Функция, которая связывает открытый неинициализированный сокет с IP-адресом, имеет такую сигнатуру:
int bind(int sockfd, struct sockaddr *my_addr, socklen_t addrlen);
Функция bind
обычно вызывается следующим образом:
struct sockaddr_in sa = {0};
int sockfd = ...;
sa.sin_family = AF_INET;
sa.sin_port = htons(port);
bind(sockfd, (struct sockaddr *)&sa, sizeof sa);
Библиотека сокетов Беркли в своей основе опирается на тот факт, что в языке Си указатель на struct sockaddr_in
может беспрепятственно преобразовываться в указатель на struct sockaddr
, а также что оба структурных типа частично совпадают по организации представления в памяти. Следовательно, указатель на поле my_addr->sin_family
(где my_addr
имеет тип struct sockaddr*
) на самом деле будет указывать на поле sa.sin_family
(где sa
имеет тип struct sockaddr_in
). Другими словами, библиотека использует каламбур типизации для реализации примитивной формы наследования.[2]
В программировании часто встречается использование структур-«прослоек», позволяющих эффективно хранить различные типы данных в едином блоке памяти. Чаще всего такой трюк используется для взаимно исключающих данных с целью оптимизации.
Числа с плавающей запятой
Предположим, требуется проверить, что число с плавающей запятой является отрицательным. Можно было бы написать:
bool is_negative(float x) {
return x < 0.0;
}
Однако, сравнения над числами с плавающей запятой являются ресурсоёмкими, так как действуют особым образом для NaN. Приняв во внимание, что тип float
представлен согласно стандарту IEEE 754-2008, а тип int
имеет размер 32 бита и за знак в нём отвечает тот же бит, что и в float
, можно применить каламбур типизации для извлечения бита знака числа с плавающей запятой, используя только целочисленное сравнение:
bool is_negative(float x) {
return *((int*)&x) < 0;
}
Такая форма каламбура типизации является наиболее опасной. Предыдущий пример опирался только на гарантии, данные языком Си в отношении представления структур и преобразуемости указателей; однако, данный пример опирается на предположения в отношении конкретного аппаратного обеспечения. В некоторых случаях, например, при разработке приложений реального времени, которые компилятор не способен оптимизировать самостоятельно, такие опасные программные решения оказываются необходимыми. В таких случаях обеспечить поддерживаемость кода помогают комментарии и проверки времени компиляции (англ. Static_assertions).
Реальный пример можно найти в коде Quake III — см. Быстрый обратный квадратный корень.
В дополнение к предположениям о битовом представлении чисел с плавающей запятой вышеприведённый пример каламбура типизации также нарушает установленные языком Си правила доступа к объектам[3]: x
объявлен как float
, но его значение считывается в выражении, имеющем тип signed int
. На многих распространённых платформах такой каламбур типизации указателей может привести к проблемам, если указатели различным образом выровнены в памяти. Более того, указатели разного размера могут осуществлять совместный доступ к одним и тем же участкам памяти[англ.], приводя к ошибкам, которые не могут быть обнаружены компилятором.
Использование union
Проблема совмещения имен[англ.] может быть решена посредством использования union
(хотя пример ниже основывается на предположении, что число с плавающей запятой представлено по стандарту IEEE-754):
bool is_negative(float x) {
union {
unsigned int ui;
float d;
} my_union = { .d = x };
return (my_union.ui & 0x80000000) != 0;
}
Это код на C99 с использованием обозначенных инициализаторов (англ. Designated initialisers). При создании объединения инициализируется его вещественное поле, а затем происходит чтение значения целого поля (физически размещенного по тому же адресу в памяти), согласно пункту s6.5 стандарта. Некоторые компиляторы поддерживают такие конструкции в качестве расширения языка — например, GCC[4].
В качестве ещё одного примера каламбура типизации см. Stride of an array[англ.] (англ.).
Паскаль
Вариантная запись позволяет рассматривать тип данных различным образом в зависимости от указанного варианта. В следующем примере предполагается, что integer
имеет размер 16 бит, longint
и real
— 32 бита, а character
— 8 бит:
type variant_record = record
case rec_type : longint of
1: ( I : array [1..2] of integer );
2: ( L : longint );
3: ( R : real );
4: ( C : array [1..4] of character );
end;
Var V: Variant_record;
K: Integer;
LA: Longint;
RA: Real;
Ch: character;
...
V.I := 1;
Ch := V.C[1]; (* Получаем первый байт поля V.I *)
V.R := 8.3;
LA := V.L; (* Сохраняем вещественное число в целочисленную ячейку *)
В Паскале копирование вещественного в целое преобразует его в округлённое значение. Данный же метод преобразует двоичное значение числа с плавающей запятой в нечто, имеющее длину длинного целого (32 бита), что не тождественно и даже может быть несовместимо с длинными целыми на некоторых платформах.
Подобные примеры могут использоваться для странных преобразований, однако, в некоторых случаях такие конструкции могут иметь смысл, например, вычисление местоположения определённых фрагментов данных. В следующем примере предполагается, что указатель и длинное целое имеют размер 32 бита:
Type PA = ^Arec;
Arec = record
case rt : longint of
1: (P: PA);
2: (L: Longint);
end;
Var PP: PA;
K: Longint;
...
New(PP);
PP^.P := PP;
Writeln('Переменная PP размещена в памяти по адресу ', hex(PP^.L));
Стандартная процедура New
в Паскале предназначена для динамического выделения памяти для указателя, а hex
подразумевается некой процедурой, печатающей шестнадцатиричную строку, описывающую значение целого. Это позволяет вывести на экран адрес указателя, что обычно запрещено (указатели в Паскале нельзя читать или выводить — только присваивать). Присваивание значения целому варианту указателя позволяет читать и изменять любой участок системной памяти:
PP^.L := 0;
PP := PP^.P; (* PP указывает на адрес 0 *)
K := PP^.L; (* K содержит значение слова по адресу 0 *)
Writeln(' Слово по адресу 0 данной машины содержит ', K);
Эта программа может работать корректно или обрушиться, если адрес 0 защищён от чтения, в зависимости от операционной системы.
См. также
Примечания
- ↑ Lawrence C. Paulson. ML for the Working Programmer. — 2nd. — Cambridge, Great Britain: Cambridge University Press, 1996. — С. 2. — 492 с. — ISBN 0-521-57050-6 (твёрдый переплёт), 0-521-56543-X (мягкий переплёт).
- ↑ struct sockaddr_in, struct in_addr . www.gta.ufrj.br. Дата обращения: 17 января 2016. Архивировано 24 января 2016 года.
- ↑ ISO/IEC 9899:1999 s6.5/7
- ↑ GCC: Non-Bugs . Дата обращения: 21 ноября 2014. Архивировано 22 ноября 2014 года.
Ссылки
- Теория:
- Benjamin C. Pierce. Types and Programming Languages. — MIT Press, 2002. — ISBN 978-0-262-16209-8.
- Язык Си — отчёты о дефектах стандарта C99:
- Раздел руководства по компилятору GCC касательно опции -fstrict-aliasing, предотвращающей некоторые каламбуры типизации
- Defect Report 257, случайно определяющий «каламбур типизации» посредством
union
и обсуждающий зависящее от реализации поведение вышеприведённого кода - Defect Report 283 об использовании типа
union
для каламбуров типизации
- Типобезопасные языки:
- Unchecked Conversion (язык Ada)
- The Unsafe structure (язык Standard ML)
- Haskell unsafeCoerce (язык Haskell)