Odpowiedź na pytanie
C++20 wprowadziło typy zmiennoprzecinkowe jako argumenty szablonowe nienumeryczne (NTTP), klasyfikując je jako typy strukturalne. Zgodnie z normą ([temp.type]/4), dwa argumenty szablonowe nienumeryczne pasują do siebie tylko wtedy, gdy są równoważne. Dla wartości zmiennoprzecinkowych równoważność jest określana na podstawie identyczności bitowej, a nie równości wartości. Oznacza to, że dwa stałe zmiennoprzecinkowe są uważane za ten sam argument szablonowy tylko wtedy, gdy mają identyczne reprezentacje obiektów (każdy bit musi pasować).
W związku z tym +0.0 i -0.0, które różnią się tylko bitem znaku w reprezentacji IEEE 754, instancjonują oddzielne szablony. Podobnie różne ładunki NaN tworzą różne typy. To w ostrej sprzeczności z zachowaniem w czasie wykonania, gdzie +0.0 == -0.0 ocenia się jako prawda, ponieważ operator równości wdraża równoważność matematyczną, podczas gdy mechanizm szablonów wymaga fizycznej identyczności.
Sytuacja z życia
Natknęliśmy się na to podczas budowania biblioteki analizy wymiarowej w czasie kompilacji dla silnika symulacji fizycznej. Użyliśmy double NTTP do reprezentowania stałych fizycznych (jak stałe grawitacyjne) i chcieliśmy wyspecjalizować rozwiązania dla teoretycznego przypadku zerowej masy (reprezentowanego jako 0.0). Jednak niektóre obliczenia constexpr, oceniające środek masy, produkowały -0.0 przez konkretne operacje arytmetyczne (np. -1.0 * 0.0).
Gdy użytkownicy przekazali wynik tych obliczeń jako argument szablonu, kompilator wybrał ogólną implementację zamiast naszej specjalizacji ZeroMass, co spowodowało regresję wydajności o 40%, ponieważ ogólna wersja wykonywała pełne odwrotności macierzy zamiast zwracać macierze tożsamościowe.
Rozważyliśmy trzy rozwiązania. Po pierwsze, mogliśmy wyraźnie wyspecjalizować zarówno dla +0.0, jak i -0.0. To podejście zapewniało poprawne działanie, ale podwajało nasze obciążenie konserwacyjne i nadal nie radziło sobie z różnymi reprezentacjami NaN lub wartościami, które były efektywnie zerowe, ale miały różne wzory bitowe z powodu błędów zaokrąglania.
Po drugie, rozważaliśmy normalizację wszystkich danych wejściowych za pomocą funkcji pomocniczej constexpr, która wymuszała zerowy bit znaku (np. value == 0.0 ? 0.0 : value). To rozwiązanie było solidne dla zer, ale wymagało makr opakowujących wokół każdego instancjonowania szablonu, zanieczyszczając API i myląc użytkowników, którzy spodziewali się bezpośredniego przekazywania parametrów.
Po trzecie, zaimplementowaliśmy warstwę normalizacji typu przy użyciu if constexpr i std::bit_cast, aby kanonizować wartości w punkcie wejścia naszych funkcji meta, skutecznie traktując wszystkie zera jako dodatnie i zbijając ciszę NaN do ładunku kanonicznego. Wybraliśmy to rozwiązanie, ponieważ zapewniało ono przejrzystość dla użytkowników biblioteki, jednocześnie zapewniając spójność wewnętrzną.
Po implementacji udokumentowaliśmy, że biblioteka traktowała wszystkie zmiennoprzecinkowe NTTP według ich reprezentacji bitowej. To rozwiązało problemy z wydajnością, choć wymagało od programistów świadomości, że -0.0 i +0.0 były odrębnymi stanami konfiguracyjnymi w systemie typów.
Czego często brakuje kandydatom
Dlaczego std::is_same_v<decltype(func<+0.0>()), decltype(func<-0.0>())> ocenia się jako fałsz, gdy +0.0 == -0.0 jest prawdziwe?
Instancjonowanie szablonów opiera się na One Definition Rule i dokładnym dopasowaniu argumentów szablonowych. Gdy kompilator napotyka func<+0.0>(), hashuje lub porównuje wzór bitowy literału zmiennoprzecinkowego. Ponieważ IEEE 754 określa, że -0.0 ma ustawiony bit znaku, podczas gdy +0.0 tego nie ma, kompilator widzi dwie różne stałe wartości i generuje dwie oddzielne instancje funkcji. Operator równości w czasie wykonania wdraża specyfikację IEEE 754, że zera ze znakami porównują się jako równe, ale mechanizm szablonów działa na poziomie reprezentacji obiektów przed zastosowaniem semantyki czasu wykonania. Kandydaci często zakładają, że ponieważ wartości są matematycznie równoważne, powinny produkować ten sam typ, myląc semantykę wartości czasu wykonania z tożsamością typu w czasie kompilacji.
Dlaczego template<float F> struct S{}; S<1.0> nie kompiluje się, mimo że 1.0 jest w normalnych wyrażeniach implicite konwertowalne na float?
Dla nienumerycznych parametrów szablonu typu zmiennoprzecinkowego standard C++20 wymaga, aby argument szablonu miał dokładnie ten sam typ co parametr; standardowe promocje i konwersje zmiennoprzecinkowe nie są dozwolone ([temp.arg.nontype]/5). Literał 1.0 ma typ double, a nie float, więc nie może być bezpośrednio przypisany do float F. Musisz użyć sufiksu float: S<1.0f>. To ograniczenie istnieje, ponieważ mangle’owanie szablonów i tożsamość typów wymagają jednoznacznej reprezentacji bez utraty precyzji konwersji. Początkujący często tego nie zauważają, ponieważ wywołania funkcji pozwalają na konwersję, ale szablony wykonują dokładne dopasowanie typów przed rozważeniem reguł konwersji.
Jak różne ładunki ciche NaN (qNaN) wpływają na instancjonowanie szablonów, gdy wszystkie reprezentują "nie liczbę"?
IEEE 754 pozwala wartościom NaN przenosić bity ładunku (informacje diagnostyczne). Ponieważ równoważność szablonów C++20 używa porównania bitowego, dwa NaN z różnymi ładunkami (np. std::numeric_limits<double>::quiet_NaN() versus wynik 0.0/0.0 na różnych sprzętach) są odrębnymi argumentami szablonowymi. Może to prowadzić do nadmiaru kodu, jeśli ścieżki kodu instancjonują szablony dla wielu bitowych wzorów NaN, lub do subtelnych naruszeń ODR, jeśli różne jednostki tłumaczenia obserwują różne reprezentacje NaN dla tego, co programista zakładał jako pojedynczą specjalizację. Kandydaci często zakładają, że NaN jest pojedynczą wartością, jak nullptr, ale w rzeczywistości reprezentuje zakres wzorów bitowych, z których każdy jest odrębny w systemie szablonów.