Союзы и типовые удары

Я долго искал, но не могу найти четкого ответа.

Многие люди говорят, что использование профсоюзов для каламбура не определено и является плохой практикой. Почему это? Я не вижу никакой причины, по которой это могло бы сделать что-то неопределенное, учитывая, что память, в которую вы записываете исходную информацию, не собирается просто менять свое согласие (если только это не выходит за рамки стека, но это не проблема объединения)., это было бы плохим дизайном).

Люди цитируют строгое правило псевдонимов, но мне кажется, будто вы говорите, что вы не можете этого сделать, потому что вы не можете этого сделать.

И какой смысл в объединении, если не вводить каламбур? Я где-то видел, что они должны использоваться для использования одной и той же ячейки памяти для разной информации в разное время, но почему бы просто не удалить информацию, прежде чем использовать ее снова?

Чтобы подвести итог:

  1. Почему это плохо использовать союзы для наказания типа?
  2. Какой смысл в них, если не в этом?

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

5 ответов

Решение

Чтобы повторить, пробивание типов через объединения прекрасно в C (но не в C++). Напротив, использование приведения указателей нарушает строгое псевдоним C99 и является проблематичным, поскольку у разных типов могут быть разные требования к выравниванию, и вы можете поднять SIGBUS, если вы сделаете это неправильно. С профсоюзами это никогда не проблема.

Соответствующие цитаты из стандартов C:

C89 раздел 3.3.2.3 §5:

если к элементу объекта объединения обращаются после того, как значение было сохранено в другом элементе объекта, поведение определяется реализацией

C11 раздел 6.5.2.3 §3:

Выражение постфикса, сопровождаемое. оператор и идентификатор обозначают член структуры или объединенного объекта. Значение соответствует названному члену

со следующей сноской 95:

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

Это должно быть совершенно ясно.


Джеймс смущен, потому что C11 раздел 6.7.2.1 §16 читает

Значение не более одного из членов может быть сохранено в объекте объединения в любое время.

Это кажется противоречивым, но это не так: в отличие от C++, в C нет концепции активного члена, и совершенно нормально получить доступ к одному сохраненному значению через выражение несовместимого типа.

См. Также Приложение C11, J.1 §1:

Значения байтов, которые соответствуют членам объединения, кроме последнего, сохраненного в [не определены].

В С99 это раньше читалось

Значение члена объединения, отличного от последнего, сохраненного в [не указано]

Это было неправильно. Поскольку приложение не является нормативным, оно не оценивало свой собственный TC, и ему пришлось ждать, пока не будет исправлена ​​следующая стандартная версия.


Расширения GNU для стандарта C++ (и для C90) явно разрешают наложение типов с помощью объединений. Другие компиляторы, которые не поддерживают расширения GNU, также могут поддерживать объединение типов, но это не является частью стандарта базового языка.

Первоначальная цель Unions состояла в том, чтобы сэкономить место, когда вы хотите иметь возможность представлять разные типы, то, что мы называем типом варианта, может быть продемонстрировано в Boost.Variant.

Другое распространенное использование - тип, определяющий правильность этого, обсуждается, но практически большинство компиляторов поддерживают его, мы видим, что gcc документирует его поддержку:

Практика чтения от другого члена профсоюза, чем тот, к которому последний раз писали (так называемое "наказание по типу"), распространена. Даже с параметром -fstrict-aliasing допускается перетаскивание типов при условии, что доступ к памяти осуществляется через тип объединения. Итак, приведенный выше код работает как положено.

обратите внимание, что даже с параметром -fstrict-aliasing разрешено перетаскивание типов, что указывает на наличие проблемы с наложением псевдонимов.

Паскаль Куок утверждал, что в отчете о дефектах 283 разъясняется, что это разрешено в C. В отчете о дефектах 283 добавлена ​​следующая сноска в качестве пояснения:

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

в С11 это будет сноска 95,

Хотя в std-discussion тема почтовой группы Тип Punning через Union аргумент сделан, это не указано, что кажется разумным, так как DR 283 не добавил новую нормативную формулировку, просто сноска:

Это, по моему мнению, недостаточно конкретная семантическая трясина в Си. Между разработчиками и комитетом Си не было достигнуто консенсуса относительно того, какие именно случаи определяют поведение, а какие нет [...]

В C++ неясно, определено ли поведение или нет.

Это обсуждение также охватывает, по крайней мере, одну причину, по которой допускать наложение типов через объединение нежелательно:

[...] правила стандарта C нарушают оптимизацию анализа псевдонимов на основе типов, которую выполняют текущие реализации.

это нарушает некоторые оптимизации. Второй аргумент против этого заключается в том, что использование memcpy должно генерировать идентичный код и не нарушает оптимизацию и четко определенное поведение, например, это:

std::int64_t n;
std::memcpy(&n, &d, sizeof d);

вместо этого:

union u1
{
  std::int64_t n;
  double d ;
} ;

u1 u ;
u.d = d ;

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

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

В блоге Type Punning, Strict Aliasing и Optimization также делается аналогичный вывод.

Обсуждение списка рассылки с неопределенным поведением: напечатайте "punning", чтобы избежать копирования, покрывающего большую часть одной и той же земли, и мы можем видеть, насколько серой может быть территория.

Существует (или, по крайней мере, было в C90) два способа сделать это неопределенное поведение. Во-первых, компилятору было разрешено генерировать дополнительный код, который отслеживал то, что было в объединении, и генерировал сигнал, когда вы обращались к неправильному члену. На практике, я не думаю, что кто-либо когда-либо делал (возможно, CenterLine?). Другой была возможность оптимизации, которая открылась, и они используются. Я использовал компиляторы, которые откладывали бы запись до последнего возможного момента, на том основании, что это может не потребоваться (потому что переменная выходит из области видимости или происходит последующая запись другого значения). Логично, что можно ожидать, что эта оптимизация будет отключена, когда объединение будет видно, но это было не в самых ранних версиях Microsoft C.

Проблемы типа наказания являются сложными. Комитет C (еще в конце 1980-х) более или менее занял позицию, что для этого следует использовать приведение (в C++, reinterpret_cast), а не объединения, хотя в то время оба метода были широко распространены. С тех пор некоторые компиляторы (например, g++) придерживаются противоположной точки зрения, поддерживая использование объединений, но не использование приведений. И на практике ни одна из них не работает, если не сразу очевидно, что существует типовое наказание. Это может быть мотивом для точки зрения g++. Если вы получаете доступ к члену профсоюза, сразу становится очевидным, что может быть наказание за тип. Но, конечно, учитывая что-то вроде:

int f(const int* pi, double* pd)
{
    int results = *pi;
    *pd = 3.14159;
    return results;
}

называется с:

union U { int i; double d; };
U u;
u.i = 1;
std::cout << f( &u.i, &u.d );

совершенно законно в соответствии со строгими правилами стандарта, но не работает с g++ (и, возможно, многими другими компиляторами); при компиляции fКомпилятор предполагает, что piа также pd не может псевдоним, и переупорядочивает запись в *pd и чтение из *pi, (Я полагаю, что это никогда не было целью, чтобы это было гарантировано. Но нынешняя формулировка стандарта гарантирует это.)

РЕДАКТИРОВАТЬ:

Поскольку другие ответы утверждают, что поведение на самом деле определено (в основном на основе цитирования ненормативной заметки, вырванной из контекста):

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

Что касается использования объединения для наказания типов: когда комитет C разрабатывал C90 (в конце 1980-х годов), имелось четкое намерение разрешить отладку реализаций, которые выполняли дополнительную проверку (например, использование жирных указателей для проверки границ). Из обсуждений того времени стало ясно, что цель состояла в том, чтобы реализация отладки могла кэшировать информацию, касающуюся последнего значения, инициализированного в объединении, и перехватывать, если вы пытались получить доступ к чему-либо еще. Это четко указано в §6.7.2.1/16: "Значение не более одного из членов может быть сохранено в объекте объединения в любое время". Доступ к значению, которого нет, имеет неопределенное поведение; это может быть ассимилировано для доступа к неинициализированной переменной. (В то время были некоторые дискуссии о том, был ли доступ к другому члену с тем же типом законным или нет. Однако я не знаю, каково было окончательное решение; после 1990 года я перешел на C++.)

Что касается цитаты из C89, высказывание о поведении определяется реализацией: найти его в разделе 3 (Термины, Определения и Символы) кажется очень странным. Мне придется поискать его в моем экземпляре C90 дома; тот факт, что он был удален в более поздних версиях стандартов, свидетельствует о том, что его присутствие комитетом было сочтено ошибкой.

Использование союзов, которые поддерживает стандарт, является средством моделирования деривации. Вы можете определить:

struct NodeBase
{
    enum NodeType type;
};

struct InnerNode
{
    enum NodeType type;
    NodeBase* left;
    NodeBase* right;
};

struct ConstantNode
{
    enum NodeType type;
    double value;
};
//  ...

union Node
{
    struct NodeBase base;
    struct InnerNode inner;
    struct ConstantNode constant;
    //  ...
};

и легально получить доступ к base.type, хотя узел был инициализирован через inner, (Тот факт, что §6.5.2.3/6 начинается с "Сделана одна специальная гарантия..." и далее явно разрешает это, является очень убедительным свидетельством того, что все другие случаи должны быть неопределенным поведением. И, конечно, это утверждение о том, что "неопределенное поведение иным образом обозначено в этом международном стандарте словами" неопределенное поведение "или пропуском любого явного определения поведения" в §4/2; чтобы утверждать, что поведение не является неопределенным, вы должны показать, где это определено в стандарте.)

Наконец, что касается наказания за тип: все (или, по крайней мере, все, что я использовал) реализации поддерживают его каким-то образом. В то время у меня сложилось впечатление, что намерение состояло в том, чтобы приведение указателей было таким, каким его поддерживала реализация; в стандарте C++ есть даже (ненормативный) текст, чтобы предположить, что результаты reinterpret_cast быть "неудивительным" для кого-то, знакомого с базовой архитектурой. На практике, однако, большинство реализаций поддерживают использование union для наложения типов при условии, что доступ осуществляется через член union. Большинство реализаций (но не g++) также поддерживают приведение указателей, при условии, что приведение указателя ясно видно компилятору (для некоторого неопределенного определения приведения указателя). А "стандартизация" базового оборудования означает, что такие вещи, как:

int
getExponent( double d )
{
    return ((*(uint64_t*)(&d) >> 52) & 0x7FF) + 1023;
}

на самом деле довольно портативны. (Конечно, он не будет работать на мэйнфреймах.) То, что не работает, это такие вещи, как мой первый пример, где псевдонимы невидимы для компилятора. (Я почти уверен, что это дефект в стандарте. Кажется, я помню, что даже видел DR по этому поводу.)

Это законно в C99:

Из стандарта:6.5.2.3 Структура и члены профсоюза

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

КРАТКИЙ ОТВЕТ: Типовое наказание может быть безопасным при нескольких обстоятельствах. С другой стороны, хотя это кажется очень хорошо известной практикой, кажется, что стандарт не очень заинтересован в том, чтобы сделать его официальным.

Я буду говорить только о C (не C++).

1. ТИП ПАННИНГА И СТАНДАРТЫ

Как уже указывалось, но в стандарте C99, а также C11, в подразделе 6.5.2.3, допускается штамповка типов. Тем не менее, я перепишу факты с моим собственным восприятием вопроса:

  • Раздел6.5 стандартных документов C99 и C11 развивает тему выражений.
  • Подраздел 6.5.2 относится к постфиксным выражениям.
  • Подраздел 6.5.2.3 рассказывает о структурах и союзах.
  • В пункте 6.5.2.3(3) поясняется оператор точки, применяемый к structили жеunion объект, и какое значение будет получено.
    Просто там появляется сноска 95. Эта сноска гласит:

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

Тот факт, чтотип punning почти не появляется, и в качестве сноски дает понять, что это не актуальная проблема в C-программировании.
Собственно,основная цель использованияunionsдля экономии места (в памяти). Так как несколько участников используют один и тот же адрес, если известно, что каждый участник будет использовать разные части программы, никогда в одно и то же время, union может быть использован вместо struct, для сохранения памяти.

  • Подраздел 6.2.6 упоминается.
  • В подразделе 6.2.6 рассказывается о том, как объекты представлены (скажем, в памяти).

2. ПРЕДСТАВИТЕЛЬСТВО ВИДОВ И ЕГО ПРОБЛЕМА

Если вы обратите внимание на различные аспекты стандарта, вы можете быть почти ни в чем не уверены:

  • Представление указателей четко не указано.
  • В худшем случае указатели, имеющие разные типы, могут иметь различное представление (как объекты в памяти).
  • union члены разделяют один и тот же адрес заголовка в памяти, и это тот же адрес, что и union сам объект
  • struct члены имеют увеличивающийся относительный адрес, начиная с того же адреса памяти, что и struct сам объект Тем не менее, байты заполнения могут быть добавлены в конце каждого члена. Как много? Это непредсказуемо. Заполняющие байты используются в основном для выравнивания памяти.
  • Арифметические типы (целые числа, действительные и комплексные числа с плавающей запятой) могут быть представлены несколькими способами. Это зависит от реализации.
  • В частности, целочисленные типы могут иметь биты заполнения. Я считаю, что это не так для настольных компьютеров. Однако стандарт оставил дверь открытой для этой возможности. Биты заполнения используются для специальных целей (четность, сигналы, кто знает), а не для хранения математических значений.
  • signed Типы могут иметь 3 способа представления: 1 дополнение, 2 дополнение, просто знаковый бит.
  • char типы занимают всего 1 байт, но 1 байт может иметь число битов, отличное от 8 (но никогда не меньше 8).
  • Однако мы можем быть уверены в некоторых деталях:

    а. char типы не имеют битов заполнения.
    б. unsigned целочисленные типы представлены в точности как в двоичной форме.
    с. unsigned char занимает ровно 1 байт без битов заполнения, и не существует никакого представления прерываний, потому что используются все биты. Более того, он представляет значение без какой-либо неопределенности, следуя двоичному формату для целых чисел.

3. ПУНКТ ТИПА против ПРЕДСТАВИТЕЛЬСТВА ТИПА

Все эти наблюдения показывают, что, если мы попытаемся сделать тип наказания с union члены, имеющие разные типы unsigned charУ нас может быть много двусмысленности. Это не переносимый код, и, в частности, мы можем иметь непредсказуемое поведение нашей программы.
Тем не менее, стандарт разрешает такой вид доступа.

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

4. БЕЗОПАСНЫЙ СЛУЧАЙ: неподписанный символ

Единственный безопасный способ использования типа наказания с unsigned char или хорошо unsigned char массивы (потому что мы знаем, что члены объектов массива являются строго смежными и нет никаких байтов заполнения, когда их размер вычисляется с sizeof()).

  union {
     TYPE data;
     unsigned char type_punning[sizeof(TYPE)];
  } xx;  

Поскольку мы знаем, что unsigned char представлен в строгой двоичной форме, без битов заполнения, здесь можно использовать тип punning, чтобы взглянуть на двоичное представление члена data,
Этот инструмент можно использовать для анализа представления значений данного типа в конкретной реализации.

Я не могу видеть другое безопасное и полезное применение типа наказания в соответствии со стандартными спецификациями.

5. КОММЕНТАРИЙ О СЛУЧАЯХ...

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

  union {
     unsigned char x;  
     double t;
  } uu;

  bool result;

  uu.x = 7;
  (uu.t == 7.0)? result = true: result = false;
  // You can bet that result == false

  uu.t = (double)(uu.x);
  (uu.t == 7.0)? result = true: result = false;
  // result == true
Другие вопросы по тегам