Sorunun tarihi
C++20'den önce, iki tam sayı veya gösterici arasındaki aritmetik ortalamayı hesaplamak, genellikle (a + b) / 2 gibi basit bir ifade ile manuel olarak gerçekleştirilirdi. Ancak bu yaklaşım, toplam türün temsil edebileceği maksimum değeri aştığında belirsiz davranışa neden olur. std::midpoint, C++20'de <numeric> başlığı altında standartlaştırılmıştır ve işaretli sayı taşması yaratmadan, integral ve gösterici türlerinin tamamında doğru sonuçlar garanti eden taşınabilir, constexpr ve noexcept bir çözüm sunmaktadır.
Sorun
İşaretli tam sayı taşması, C++'ta belirsiz davranıştır, hatta C++20'nin iki'nin tamamı zorunluluğuna rağmen. İki büyük pozitif işaretli tam sayının (örneğin INT_MAX yakınında) ortalaması alınırken, toplam taşar. Benzer şekilde, iki büyük negatif tam sayının toplamı da alt taşma yapabilir. İşaretçi aritmetiği taşmada belirsiz davranış gösterse de, aynı dizi içindeki iki işaretçi arasındaki farkın std::ptrdiff_t'ye sığacağı garanti edilir ve bu, tamsayı toplamındaki taşma riskini ortadan kaldırır. Zorluk, ekleme işlemi sırasında taşmayı önlerken, işaret karışık girişler için doğruluğu koruyan bir ortalama algoritması uygulamaktır.
Çözüm
std::midpoint, işaretli tam sayılar için operatörleri çıkarmadan önce işaretsiz karşılıklarına dönüştürerek taşmayı önler. a > b durumunda ortalamayı a - (unsigned_type(a) - b) / 2 olarak hesaplar veya aksi durumda simetrik eşdeğerini kullanır. Bu, işaretsiz türlerin iyi tanımlı modüler aritmetiğinden yararlanarak sayılar arasındaki yarı mesafeyi hesaplar ve asla girişlerden daha büyük bir ara değer oluşturmadan sonuç verir. Göstericiler için uygulama, first + (last - first) / 2 ifadesini kullanır, çünkü (last - first) arifesi iyi tanımlı ve temsil edilebilir olup, bunun yarısının hesaplanması, dizinin geçerli aralığında kalınmasını sağlar.
#include <numeric> #include <limits> #include <iostream> int main() { int a = std::numeric_limits<int>::max() - 10; int b = std::numeric_limits<int>::max() - 2; // int naive = (a + b) / 2; // Belirsiz davranış: taşma int safe = std::midpoint(a, b); // İyi tanımlı: max - 6 döner int arr[] = {1, 2, 3, 4, 5}; int* mid = std::midpoint(&arr[0], &arr[4]); // arr[2]'ye işaret eder }
Sorun tanımı
Yüksek frekanslı bir ticaret platformunda, sistemin Unix çağından itibaren nanoseniyeler olarak temsil edilen iki sipariş zaman damgası arasında zamansal ortalama hesaplaması gerekiyordu. Pazar açılışına yakın stres testleri sırasında, zaman damgaları INT64_MAX'a yaklaşırken, eski hesaplama (t1 + t2) / 2 işaretli tam sayı taşması nedeniyle aralık duygusu motorunun öncelik sırasını bozarak aralıklı çöküşlere neden oldu.
Düşünülmüş farklı çözümler
Çözüm 1: Yerleşik genişletilmiş hassasiyet türlerini kullanma
Ekip, ara hesaplama için __int128_t'ye casting yapmayı, sonra geri döndürmeyi düşündü. Bu yaklaşım, desteklenen derleyicilerde (GCC, Clang, MSVC) doğru aritmetik garantisi vermekle birlikte, katı C++17 standartlarına uyum gerektiren gömülü hedeflerde taşınabilirlikten yoksundu ve 128-bit emülasyonu maliyetli olduğundan 32-bit mimarilerde ince performans düşüşleri getirdi.
Çözüm 2: İşaretsiz aritmetik cast Her iki zaman damgasını da std::uint64_t'ye dönüştürmek, ortalamayı yapmak ve geri dönüştürmek önerildi. Bu, pozitif değerler için taşmayı önlese de, tarihsel zaman damgalarıyla (işaretli temsil altında negatif değerler) çalışırken uygulama tanımlı sonuçlar üretmekteydi, çünkü negatif değerlerin işaretsiz dönüşümü büyük pozitif büyüklüklere (2'nin tamamı sarma) yol açıyordu ve bu da sıfırın üzerinden hesaplamalarda matematiksel olarak yanlış bir ortalama sağlıyordu.
Çözüm 3: Taşma kontrolüyle manuel dallanma
Operatörlerin işaretlerini kontrol eden ve koşullu olarak a + (b - a)/2 veya bit düzeyinde manipülasyon kullanarak özel bir fonksiyon uygulamak düşünüldü. Bu doğru sağlasa da, dallanma yükü ve karmaşıklık ekledi. Birden fazla sayısal alanda (koordinatlar, fiyatlar, zaman damgaları) bu mantığı sürdürmek DRY ilkesini ihlal etti ve bakım hatası riskini artırdı.
Çözüm 4: C++20 std::midpoint'i benimseme Araç zincirini C++20'ye yükselterek std::midpoint'i kullanmak, tüm kenar durumlarını, işaretli girişler ve tam sayı alanlarının sınırları yakınındaki değerleri doğru bir şekilde ele alan sıfır yüklemeli bir soyutlama sağladı.
Seçilen çözüm ve sonuç
Ekip Çözüm 4'ü seçerek hesaplama katmanını C++20'ye geçirdi. Değişiklik, üretimdeki taşma çöküşlerini ortadan kaldırdı, özel güvenli-matematik araçlarının kod tabanında 200 satırlık bir azalma sağladı ve dallanma mantığını ortadan kaldırarak önbellek yerelleşmesini geliştirdi. Regresyon testleri, yayılma yaklaşımıyla elde edilen aynı performansı x86-64 üzerinde doğruladı ve derleme zamanında sabitler için constexpr değerlendirmesi avantajı sağladı.
Soru 1: Neden işaretli tam sayıları işaretsiz türlere casting yapmak, genel bir orta nokta hesaplaması için yetersizdir?
Cevap İşaretsiz aritmetik, 2^N modülünde sarar ve taşmayı önler, ancak negatif bir işaretli tam sayıyı işaretsiz olarak dönüştürmek büyük pozitif bir değere yol açar (örneğin, -1 UINT_MAX olur). İşaretsiz aritmetik ile negatif ve pozitif bir zaman damgasını ortalamak, işaretsiz aralığın ortasına yakın bir sonuç verir, doğru işaretli ortalamayı sağlamaz. std::midpoint, işaret durumunu korur ve fark tek sayılı olduğunda ilk argüman yönünde düzgün bir şekilde yuvarlar, sonuç için son işaretsiz sarma kullanmadan işaret bitini doğru bir şekilde işler.
Soru 2: İşaretçiler için std::midpoint'in belirsiz davranışa neden olduğu özel nesne modeli kısıtlaması nedir?
Cevap
std::midpoint, her iki işaretçinin aynı dizi nesnesinin (veya son elemandan bir sonrasını) işaret etmesini gerektirir. Eğer işaretçiler farklı dizilere veya ilgisiz belleğe referans veriyorlarsa, davranış belirsizdir çünkü last - first ifadesi (dahili olarak kullanılır) sadece aynı dizi içindeki işaretçiler için tanımlıdır. Bu, işaretli ortalamadan ince bir ayrımı ifade eder; adaylar genellikle bunun yalnızca basit bir sayısal ortalama gibi çalıştığını varsayıp işaretçi aritmetiği için katı işaretleme ve nesne modeli gereksinimlerini gözden kaçırabilirler.
Soru 3: std::midpoint, tam sayılarla karşılaştırıldığında özellikle NaN değerleri açısından işlevselliğini nasıl farklılaştırır?
Cevap
Sürekli sayı türleri için std::midpoint, toplamda taşma ve alt taşmayı (infinity veya sıfırın yanlış bir şekilde üretilmesini) önlerken, genelde a/2 + b/2 gibi tanımlanmış bir strateji kullanır. Önemle, eğer herhangi bir argüman NaN ise, std::midpoint NaN döner. Eğer bir argüman pozitif sonsuz, diğeriyse negatif sonsuzsa, belirsizliği (NaN) döner; oysa tam sayı ortalaması yalnızca taşar veya sarar. Adaylar genellikle bunun basit bir aritmetik gerçekleştirdiğini varsayıp, IEEE 754 özel değer yayılma kurallarını göz önünde bulundurmadan çalıştığını düşünürler.