1 Востаннє редагувалося wander (16.10.2019 08:50:52)

Тема: Portable type punning in modern C++.

Ну, що ж раз просять, то будемо оживляти розділ по С++. Вирішив трохи поговорити про доволі тривіальну задачу, з дуже не тривіальним її вирішенням. Так, мова йтиме про переносиму/портабельну версію type punning або про щось дуже схоже на нього.

Трохи про те, що це таке, якщо перекладати дослівно то,  "pun" - це жарт, що має кілька значень або звучить як інше слово. Тобто, це деяка гра слів, яка використовує різні значення терміна або схожих за звучанням слів. Ну, а власне "type" - це тип, думаю тут зрозуміло. Тобто, якщо переводити на комп'ютерний сленг, то це є деякий хак, який обходить типову (від слова "type") систему, щоб досягти ефекту, який було б важко або неможливо досягти в межах правил мови програмування.

Якщо говорити ще більш конкретно в рамках С/С++, то ми інколи хочемо перетворити об'єкт типу T в об'єкт типу U, тобто зробити приведення між непов'язаними типами. В С та і загалом в С++, зазвичай це досягається шляхом використання union’ів, а саме записом в одне поле, а читанням з іншого.

Ця практика є настільки популярною, що її використання можна знайти майже у будь-якому більш менш великому проєкті, де потрібно було отримати так хоч теж банальне бітове представлення числа. Часте використання type punning можна зустріти, наприклад, в ядрі Лінукса або у source-кодах Apple, проте це все мова про С, де така штука як type punning загалом well-defined. У С++ же type punning спричиняє невизначену поведінку.
І на цьому можна було б розійтися але, що якщо мені потрібно зробити це, невже нічого з цим не зробити, як говориться hold on. На С++ можна знайти багато прикладів використання type punning, по аналогії з С і не лише на SO, але і у кодах доволі великих проєктів написаних уже на С++ , наприклад у гуглівському фреймворку для юніт тестування GTEST.  У них є такий макрос як ASSERT_DOUBLE_EQ, який порівнює числа з рухомою комою і якщо подивитися як він реалізований можна побачити таке:

Прихований текст
// Я прибрав все лишнє для простоти.
template <typename RawType>
class FloatingPoint 
{
    ...
    static RawType ReinterpretBits(const Bits bits)
    {
        FloatingPoint fp(0);
      fp.u_.bits_ = bits;    
        return fp.u_.value_;   // <--- осьо
    }
    ...
    private:
        union FloatingPointUnion 
        {
            RawType value_;  
            Bits bits_;      
        };
    ...
    FloatingPointUnion u_;
};

В даному випадку згідно стандарту це UB чистої води, не можна читати з неактивного поля union’а в С++. Вся справа у strict aliasing'y, невже хлопці з гугл цього не знають? :)

N4659 12.3/1 написав:

In a union, a non-static data member is active if its name refers to an object whose lifetime has begun and has not ended. At most one of the non-static data members of an object of union type can be active at any time, that is, the value of at most one of the non-static data members can be stored in a union at any time.

З усім тим, як ми бачимо ця "техніка" є доволі потрібною не дивлячись нінащо. Можливо тоді все ж існує яке рішення, невже немає ніяких виходів? Звернімось з цим питання до інтернету, зайшовши на кілька перших посилань на SO, можна дійсно побачити як приклади з union’ами, так і з використанням memcpy - мовляв через memcpy це не UB, та чи дійсно це так?
Подивімось, що каже стандарт:

N4659 6.9/2 написав:

For any object of trivially copyable type T, whether or not the object holds a valid value of type T, the underlying bytes making up the object can be copied into an array of char, unsigned char, or std::byte. [...]

N4659 6.9/3 написав:

For any trivially copyable type T, if two pointers to T point to distinct T objects obj1 and obj2, where neither obj1 nor obj2 is a base-class subobject, if the underlying bytes making up obj1 are copied into obj2, obj2 shall subsequently hold the same value as obj1.

Тут напрошується доволі чіткий висновок, про те, що, якщо ми хочемо скопіювати два об'єкти (underlying bytes), то ми це можемо зробити тільки тоді, коли вони мають the same trivially copyable type T. Звідси, той же punning між int і float навіть через memcpy - UB? Чи ні? Якщо зайти в документацію і почитати опис memcpy, то можна помітити одну цікаву річ:

cpp/string/byte/memcpy написав:

Copies count bytes from the object pointed to by src to the object pointed to by dest. Both objects are reinterpreted as arrays of unsigned char.

Гм, то все ж це не UB? Адже memcpy інтерпретує об'єкти як масив чарів отже вони зводяться до одного типу, тобто все ОК? Загалом так, але з точки зору стандарту це виглядає трохи дивно, адже сам стандарт нічого такого не обіцяє. Тобто всі ці штуки з інтерпретацією, які робить memcpy залежать від реалізації, тобто це все ж ходіння по краю леза, якщо ми хочемо мати  якусь портабельну версію type punning'a.

То невже немає ніякого рішення? Якщо ми говоримо про С++17, то, напевне ні, немає. Загалом, якщо не стоїть питання про написання саме portable версії, яка має працювати завжди і всюди, то все не так погано. Тоді можна скористатися і memcpy, іякщо ви добре знаєте як цю ситуацію обробляють компілятори з якими ви працюєте, то можете навіть користуватися версією з union’ами. Зазвичай, якщо ваш компілятор підтримує С, то швидше за все і в С++ у вас не буде проблем з type punning'ом через union, ба більше компілятори добре розуміють, що ви хочете зробити і швидше за все згенерують однаковий вихлоп, що для версії з використанням memcpy, що з union’ами. Бо розробники компіляторів теж не ідіоти і не стануть спеціально вставляти палки в колеса, принаймні більшість з них :) (цікаво чи хлопці з гугл це перевірили?)

Проте, якщо вже говорити про свіжий С++20 то там з'явився новий вид перетворення під назвою bit_cast, який покликаний зробити за нас все те, що ми хотіли

N4830 26.5.3/1 написав:

Returns: An object of type To. Each bit of the value representation of the result is equal to the corresponding bit in the object representation of From.

А що ви думаєте з цього приводу? Як ви викручується з ситуації, діліться :)

Подякували: leofun01, Arete2

2

Re: Portable type punning in modern C++.

1. Type puning за визначенням не дуже portable.
2. reinterpret_cast чому забули?

Подякували: leofun011

3 Востаннє редагувалося wander (15.10.2019 18:40:39)

Re: Portable type punning in modern C++.

reinterpret просто менш цікавий кейс)
Там теж по факту завжди UB

Ну чому ж не дуже portable? Це ж не лише якась локальна С/С++ штука, так назва не дуже, але всі ж знаю що під цим терміном мається на увазі.

4 Востаннє редагувалося koala (15.10.2019 19:16:32)

Re: Portable type punning in modern C++.

На різних архітектурах, системах і навіть компіляторах у межах однієї ОС:
- різні розміри типів (sizeof);
- різні характеристики типів (std::numeric_limits<T>::is_iec559 буває і false);
- різні порядки байтів;
- різне вирівнювання.

Звісно, це все можна врахувати і написати силу різних реалізацій для усіх можливих ситуацій з усіма потрібними застереженнями (от тільки ніколи не вгадаєш, що буде в наступній версії компілятора); але далеко не факт, що конкретний type punning дасть потрібний ефект у кожній з них.

Подякували: leofun011

5

Re: Portable type punning in modern C++.

Я думаю головне, щоб інструмент був який би не суперечив правилам мови, а там вже обернути це все над різними системами простіше, он boost якось живе)

6

Re: Portable type punning in modern C++.

adziri написав:

Я думаю головне, щоб інструмент був який би не суперечив правилам мови, а там вже обернути це все над різними системами простіше, он boost якось живе)

А який сенс? Type punning працює лише в конкретних системах. Ви не зможете додавати float-и як int-и, якщо вони різної довжини чи напрямку.

7

Re: Portable type punning in modern C++.

koala написав:
adziri написав:

Я думаю головне, щоб інструмент був який би не суперечив правилам мови, а там вже обернути це все над різними системами простіше, он boost якось живе)

А який сенс? Type punning працює лише в конкретних системах. Ви не зможете додавати float-и як int-и, якщо вони різної довжини чи напрямку.

Ну, не лише ж для перетворення float-ів в int-и використовують type punning.
І зрештою з С++20, все одно це стандартизували, хоча і для тих же float-ів і int-ів.

8

Re: Portable type punning in modern C++.

adziri написав:

? Як ви викручується з ситуації

Який би там не був UB в межах стандарту, на виході отримуємо виконуваний файл, поведінка якого цілком визначена.
Тестуємо на потрібних нам системах. Якщо все ок - в продакшин.

9

Re: Portable type punning in modern C++.

leofun01 написав:
adziri написав:

? Як ви викручується з ситуації

Який би там не був UB в межах стандарту, на виході отримуємо виконуваний файл, поведінка якого цілком визначена.
Тестуємо на потрібних нам системах. Якщо все ок - в продакшин.

Проблема, як на мене, не стільки в UB, наскільки в тому, що політика С++ якась трохи не зрозуміла, бо вже якось дуже намудрували з цим і всеодно компілятори дозволяють проводити такі перетворення. Тобто мати універсальний інструмент на даний момент не можливо, навіть в С++20, але кого це зупиняло? От взяти embedded там повно всяких хаків (не лише типу type punning), які UB і які тим не менш працюють )