Каламбур типизации

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

Языки Си и C++ предоставляют явные возможности каламбура типизации посредством таких конструкций, как приведение типов, union, а также reinterpret_cast для C++, хотя стандарты этих языков некоторые случаи таких каламбуров трактуют, как неопределённое поведение.

В языке Pascal записи с вариантами могут использоваться для интерпретации конкретного типа данных более, чем одним способом, или даже не предусмотренным языком способом.

Каламбур типизации является прямым нарушением типобезопасности. Традиционно возможность построить каламбур типизации связывается со слабой типизацией, но и некоторые сильно типизированные языки или их реализации предоставляют такие возможности (как правило, используя в связанных с ними идентификаторах слова unsafe или unchecked). Сторонники типобезопасности утверждают, что «необходимость» каламбуров типизации является мифом[1].

Пример: сокеты

Классический пример каламбура типизации можно видеть в интерфейсе сокетов Беркли. Функция, которая связывает открытый неинициализированный сокет с 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 защищён от чтения, в зависимости от операционной системы.

См. также

Примечания

  1. 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 (мягкий переплёт).
  2. struct sockaddr_in, struct in_addr. www.gta.ufrj.br. Дата обращения: 17 января 2016.
  3. ISO/IEC 9899:1999 s6.5/7
  4. GCC: Non-Bugs

Ссылки

This article is issued from Wikipedia. The text is licensed under Creative Commons - Attribution - Sharealike. Additional terms may apply for the media files.