Tip sisteminizden yararlanın
(dzombak.com)- Programlama sırasında tip sistemi kullanılarak farklı veri anlamları açıkça birbirinden ayrılabilir
- String veya integer gibi genel tipleri olduğu gibi kullanmak, bağlamın kaybolmasına yol açar ve hatalara neden olabilir
- Aynı temel tipe dayansa bile amaca uygun yeni tipler tanımlanırsa, derleme zamanında hatalarla yanlışlar önlenebilir
- Go kütüphanesi libwx, ölçü birimlerini açıkça ayıran tipler tanımlayarak float64 karışımından kaynaklanan hataları önler
- Örnek kodda UUID tipi UserID ve AccountID olarak ayrılarak yanlış kullanım derleyici tarafından engellenir
- Go gibi tip sistemi çok güçlü olmayan dillerde bile basit tip sarmalama ile hatalar önlenebilir
Tip sistemini aktif biçimde kullanalım
Sorunun başlangıç noktası: basit tiplerin karıştırılması
- Programlamada
string,int,UUIDgibi temel tiplerle birçok değeri ifade etmek yaygındır - Ancak projenin ölçeği büyüdükçe bu basit tiplerin ayırt edilmeden birbirine karıştırılarak kullanılması sık görülür
- Örnek: userID string’ini yanlışlıkla accountID olarak geçirmek ya da
intparametresi 3 tane olan bir fonksiyonda sırayı yanlış vermek
- Örnek: userID string’ini yanlışlıkla accountID olarak geçirmek ya da
Çözüm: niyeti ortaya koyan tip tanımları
intvestringyalnızca yapı taşlarıdır; bunları sistem genelinde olduğu gibi geçirmek anlamlı bağlamın kaybolmasına neden olur- Bunu önlemek için her role özgü benzersiz tipler tanımlanmalı ve kullanılmalıdır
- Örnek:
type AccountID uuid.UUID type UserID uuid.UUID func UUIDTypeMixup() { { userID := UserID(uuid.New()) DeleteUser(userID) // hata yok } { accountID := AccountID(uuid.New()) DeleteUser(accountID) // hata: AccountID tipi UserID olarak kullanılamaz } { accountID := uuid.New() DeleteUserUntyped(accountID) // derleme zamanında hata yok, çalışma zamanında sorun çıkma olasılığı yüksek } }
- Örnek:
- Böylece yanlış tipte argümanlar derleme zamanında engellenebilir
Gerçek uygulama örneği: libwx kütüphanesi
- Yazar, kendi Go kütüphanesi libwx içinde bu tekniği uyguluyor
- Tüm ölçü birimleri için özel tipler tanımlanıyor ve birim dönüşüm metotları da bu tiplere bağlanıyor
- Örnek:
Km.Miles()metodu ile birimler açıkça ayrılıyor
- Örnek:
- Aşağıda, yanlış fonksiyon argüman sırası ile birim karışıklığını derleyicinin engellediği bir örnek yer alıyor:
// Fahrenheit sıcaklığı tanımı temp := libwx.TempF(84) // Bağıl nem tanımı (yüzde) humidity := libwx.RelHumidity(67) // Fahrenheit yerine Celsius sıcaklığı bekleyen fonksiyona yanlış değer geçiliyor fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointC(temp, humidity)) // derleyici tip uyuşmazlığı hatasını anında yakalar // temp (TempF tipi) TempC olarak kullanılamaz // Fonksiyona argüman sırası yanlış veriliyor fmt.Printf("Dew point: %.1fºF\n", libwx.DewPointF(humidity, temp)) // derleyici argüman tip hatasını engeller - Yalnızca
float64kullanılsaydı ortaya çıkabilecek hataların tamamı önlenebilir
Sonuç: tip sistemini aktif kullanın
- Tip sistemi yalnızca sözdizimi denetimi için değil, hata önleme aracıdır
- Her model için ID tipleri ayrı tanımlanmalı, fonksiyon argümanları da float veya int yerine açık tiplerle sarılmalıdır
- Bu yaklaşım, Go gibi tip sistemi çok güçlü olmayan dillerde bile çok etkilidir ve uygulanması da basittir
- Gerçek dünyada UUID veya string tiplerinin karıştırılmasından kaynaklanan hatalar gerçekten çok yaygındır
- Yazar, bu basit yaklaşımın üretim kodunda yaygın biçimde kullanılmamasının şaşırtıcı olduğunu vurguluyor
İlgili kod
- Tüm örnek GitHub’da görülebilir:
https://github.com/cdzombak/libwx_types_lab
8 yorum
Kotlin'de kullanmaya çalıştığınızda, primitive'lerin wrapper'larla sarılması nedeniyle stack yerine heap'te saklanmasından kaynaklanan bir performans sorunu olabileceğini biliyorum. Elbette çoğu kullanım senaryosunda bakım kolaylığı önceliklidir. Ayrıca, performans sorununu en aza indirmek için value class kullanılabilir.
Ada dili bu açıdan gerçekten çok iyi bir tip sistemine sahip. Türü farklı olan değerler kolayca ayrı tipler olarak tanımlanabiliyor ve karıştırıldıklarında derleyici bunu iyi yakalıyor.
Merakımdan soruyorum. Diğer yaygın tipli dillerden farklı başka avantajları da var mı? (
kotlin,rust,typescript, ... )Ada'nın avantajları genel olarak "C'den daha iyi" tarafında. C'de geliştiriciye güvenilip izin verilen şeylerin kısıtlanmaması büyük bir etken; örtük tür dönüşümleri gibi şeyler de buna dahil. Ama çoğu geliştirici alışkın oldukları için sanırım yine de C'yi daha çok seviyor gibi görünüyor...
Bu, üzerinde çalıştığım kod tabanının bir özelliği olabilir ama neredeyse her şeyi ayrı bir tür olarak tanımlayıp kullanıyoruz. Temel türleri kullandığımız tek yer neredeyse dizi indeksleri.
Anladım, teşekkürler
Hacker News yorumu
Bu yaklaşımı seviyorum; “kötü durumu ifade düzeyinde imkânsız kılmak” fikrine dayanıyor. Ancak bu kalıpta sık görülen sorun, geliştiricilerin tip uygulamasının yalnızca ilk aşamasında kalması. Her şey bir tipe dönüşüyor, birbirleriyle iyi uyum sağlamıyor ve hafifçe değiştirilmiş çok sayıda tip ortaya çıktığı için kodu takip etmek ve anlamak zorlaşıyor. Böyle bir durumda, zayıf tip uygulanmış dinamik bir dilde (JS) ya da güçlü tipli dinamik bir dilde (Elixir) yazmayı tercih ederim. Ama geliştiriciler koşullu mantığı pattern matching yapılabilen union type’lara taşımaya devam eder, delegation’ı iyi kullanır ve tip güdümlü akışı ileri götürürse, geliştirme deneyimi yeniden keyifli hale geliyor. Örneğin
DewPointfonksiyonu, birden çok tipi kabul etse de doğal biçimde çalışacak şekilde tasarlanabilir.Bu yüzden daha fazla dilin bounded (Integer aralığı kısıtlı) tipleri yerleşik olarak desteklemesini isterdim. Örneğin
x: u32yerine, tip sistemininxiçin yalnızca[0,10)aralığını zorunlu kılabilmesini isterdim. Böylece dizi indekslemede bound check gerekmezdi.Optiongibi durumlarda da peephole optimizasyonu çok daha kolay olurdu. Rust’ta fonksiyon içinde LLVM sayesinde bunun bir kısmı var, ama fonksiyonlar arasında değişken aktarımında yok.Bu arada Ruby zayıf tipli değil, güçlü tipli bir dildir.
1 + "1"gibi bir işlem yaparsanızTypeError: String can't be coerced into Integerbenzeri bir hata alırsınız.“Tip uygulamasının ilk aşamasında kalmak” başarısızlığın nedeni. Örneğin
int’istructile sarıp UUID olarak kullanmaya başlamak iyi bir başlangıçtır; ama biri elindeki herhangi birint’i sarıp geçirirse, gerçekte benzersiz olması gereken UUID özelliği bozulabilir. Sonuçta önemli olan “Correct by construction” yani daha inşa edilirken doğruluğun garanti edilmesi. UUID gibi benzersiz olması gereken tipler, bir fonksiyon ya da kurucu içinde istisna fırlatmak dâhil gerçekten kanıtlanmadıkça üretilememeli. Bu kavram yalnızca UUID için değil, her tip ve invariant için geçerli.Son zamanlarda Red-Green-Refactor kalıbını izliyorum; ama başarısız test yazmak yerine tip sistemini daha katı hale getirip hataların type checker tarafından yakalanmasını sağlıyorum. Yeni özellikler, edge case’ler ya da tiplerin hata üretmesini sağlayamadığı bug’lar için hâlâ test kullanıyorum; ama tip sisteminden yararlanan red-green-refactor genelde daha hızlı ve büyük bir hata sınıfını tamamen engelleyebiliyor.
Structural type’larla sorunların çoğu hafifletilebilir. Gerçekten gerekirse nominal type’larla zorunlu da kılınabilir.
İstisnalar ve tiplerle komşu bir konu olarak, checked exception’ların iyi kullanıldığında tipe göre uygun işlemeyi sağladığını düşünüyorum. Java’nın checked exception’larının neden bu kadar eleştirildiğini hiç anlamadım. Sorumlu olduğum bir projede checked exception kullanımını zorunlu kıldığımda başlangıçta herkes nefret etti; ama kod akışındaki tüm istisna durumlarını düşünmeye alışınca herkes sevmeye başladı. Unit testlerde o kadar katı değildik ama proje çok sağlam hale geldi.
Java checked exception’larına yönelik şikâyet, istisna işlemenin çok zahmetli olması. Kütüphane yazarı checked exception’ları net biçimde belirleyemiyor ve istemci tarafında her fonksiyon çağrısında gereksiz yere exception handling yapmak gerekiyor; bu da insanı soğutuyor. İstisnaları başka tiplere ya da runtime exception’a kolayca dönüştürebilmek veya modül/uygulama düzeyinde sadece bildirmek mümkün olsa bu sorun azalırdı, ama fazlasıyla zahmetli. Ayrıca imzaları kolay bozduğu için alan-özel istisnalar kullanmak gerekiyor; Java da exception dönüştürmeyi rahatsız edici hale getiriyor. Checked exception iyi bir fikir, ama Java’daki exception handling kullanılabilirliğini sevmiyorum.
Checked exception’ların eleştirilmesinin nedeni kötüye kullanımlarıydı. Java’nın hem checked hem unchecked exception desteklemesi iyi bir tercih. Ama Eric Lippert’in dediği gibi yalnızca “exogenous” türü istisnalarda checked exception kullanıp çoğunu unchecked’e çevirmek daha doğru. Örneğin veritabanı bağlantısı her an kopabilir; ama
throws SQLExceptionifadesini tüm call stack boyunca taşımak fazla zahmetli. En üst seviyede catch-all ile yakalayıp HTTP 500 dönmek yeterli. İlgili yazıChecked exception’lar (unchecked olanlara kıyasla), call stack’in derinindeki bir fonksiyon istisna fırlatacak şekilde değişirse yalnızca handler’ı değil aradaki tüm fonksiyonları da değiştirmeyi gerektirebilir. Yani sistem değişikliğinde esnekliği azaltır. Async function coloring tartışması da benzer bağlamda. Bir fonksiyon exception fırlatabiliyorsa ya
try/catchile sarmalamanız ya da çağıranın da exception fırlattığını beyan etmesi gerekir.C# tipler konusunda açık ama unchecked exception yaklaşımını benimsemiş. Hata stack’i temiz kalıyor ve sorun çıkmıyor. Her seviyede özel özel iş yapan pattern matched exception handler’lardan daha temiz. Sağlam bir unwrap edilebilir error result varsa onun da benzer olduğunu düşünüyorum.
Java’da checked type’ların kullanılabilirliği zayıf; örneğin stream API kullanırken
map/filterfonksiyonlarında checked exception fırlatmak gerçekten can sıkıcı. Birden fazla servis çağrısının her birinin kendi checked exception’ı varsa, sonunda yaExceptionyakalamak ya da absürt derecede uzun exception listeleri yazmak zorunda kalıyorsunuz.Genel olarak “özgün tipler oluşturma” yaklaşımına katılıyorum; ama her şeyin özgün tip olduğu sistemlerde çok zorlandığım oldu. Özellikle sadece byte taşıyan kodla alan hesaplaması yapan kod birbirine karıştığında iş gerçekten zorlaşıyor.
O hissi anlıyorum. Elinizde zaten gerekli veri var ama önce bir tip oluşturmanın ya da bir instance üretmenin yolunu bulmanız gerekiyor; ortada tarif yoksa dokümantasyonla boğuşuyormuşsunuz gibi geliyor. Örneğin elinizde
{x, y, z}nesnesi var ama öncecreateVector(x, y, z): Vectorkullanmanız gerekiyor; sonra birFaceoluşturmak içincreateFace(vertices: Vector[]): Facegibi şeyler çağırıyorsunuz, bu da gereksizce prosedürel hissettiriyor. BouncyCastle gibi durumlarda da byte array hazır olsa bile, istediğiniz işleve ulaşmak için önce birden fazla tip oluşturup bunların method’ları üzerinden ilerlemeniz gerekiyor.Go’da type alias’ı asıl tipe geri çevirmek (
AccountID → intgibi) oldukça kolay. Yapıyı düzgün kurarsanız, alan mantığı tarafı type alias’ları kullanırken alanı umursamayan kütüphane tarafı higher/lower tipler arasında çeviri yaparak clean architecture tarzı bir düzen de kurabilir. Ama bunun için çok fazla dönüşüm kodu gerekiyor.Phantom type’lar bu durumda kullanışlı. Tip parametresi yani generic ekliyorsunuz, ama o parametreyi gerçekte hiçbir yerde kullanmıyorsunuz. Eskiden Scala’da kriptografi kodu yazarken dizilerin hepsi byte idi; ama phantom type’larla bunların birbirine karışmasını önlüyordum. İlgili örnek
İdeal durumda derleyicinin yalnızca tipleri denetlemesini, geri kalan alan mantığını da basit byte kopyalamaya indirmesini isterdim. Tabii seni doğru anladıysam.
Bence tip sistemlerinde de 80/20 kuralı geçerli. Aşırıya kaçınca kütüphane kullanımı külfetli hale geliyor ve gerçek getirisi çok az oluyor. UUID ya da
Stringgibi şeyler tanıdık, amaAccountID,UserIDgibi şeyleri bilmediğim için önce öğrenmem gerekiyor; bunun da maliyeti var. Gelişmiş tip sistemleri değerli olabilir de olmayabilir de, özellikle yeterince testiniz varsa. İlgili notSonuçta yazılımı kullanmak için
Accountya daUserın ne olduğunu zaten bilmek zorundasınız. O yüzdengetAccountByIdgibiAccountIdalan bir fonksiyonu anlamak, UUID alan bir fonksiyonu anlamaktan daha zor değil bence.Aslında
Stringyalnızca bir byte kümesi, tek başına hiçbir anlamı yok.AccountIDise çoğu durumda “hesabın kimliği” demek. İç temsili gerçekten merak ediyorsanız tip tanımına bakarsınız; ama çoğu bağlamdaAccountID’nin ne olduğunu bilmek yeterli. Tip denen şey, üstüne açık bir ad konunca kullanımı daha az kafa karıştırıcı oluyor. grugbrain.dev bağlantısı bence tersine fazla temel düzeyde; bir grug brain olsaydı bu kadar tip ayrımını muhtemelen desteklerdi.foo(UUID, UUID)yerinefoo(AccountId, UserId)çok daha iyi. Kendi kendini açıklıyor ve çağrıda sırayı yanlışlıkla değiştirirseniz derleyici bunu yakalayabiliyor. Karmaşık veri yapılarında da yeni tip oluşturmadan daha açık yazabiliyorsunuz.“UUID ya da String zaten tanıdık” denmesine karşılık, gerçekte UUID’nin GUIDv1, UUIDv4, UUIDv7 gibi hangi biçimde saklandığını/dönüştürüldüğünü anlamak her zaman kolay değil. Deneyimime göre Java + MS SQL kombinasyonunda UUID ile
uniqueidentifierarasında dönüşüm yaparken endianness dönüşümü nedeniyle elle müdahale etmem gerekmişti. Tahminen veritabanlarının timezone’u otomatik çevirirken çıkardığı sorunlara benzer bir şey.Aslında bu tipleri anlamak zaten yapılması gereken işti; yoksa yanlış veriyi olduğu gibi fonksiyona geçebilirdiniz.
Yakın zamanda bizim ekip de C++ kodunda farklı sayısal değerlerin birbirine karıştığı yerlere tip uyguladı. Bir hatayı bulup düzeltirken güvenli tipler eklemeye başladık ve sonra benzer yanlış değer kullanımının üç yerde daha olduğunu gördük.
mp-units(mp-units resmi dokümanı) kütüphanesi bu tür fiziksel birim sorunlarına odaklanan güzel bir örnek. Güçlü birim tipleri kullanınca güvenlik sağlanıyor, karmaşık birim dönüştürme mantığı otomatikleşiyor ve generic kodla farklı birimler işlenebiliyor. Bunu Prolog dünyasına taşımayı denemiştim ama çevremde pek ilgi görmedi. Prolog örneği
Bir zamanlar mesafe, hız, sıcaklık, basınç gibi farklı fiziksel niceliklerle çalışan bir projede bulundum; her şey sadece
floatolarak aktarılıyordu, bu yüzden mesafeyi hız yerine verseniz derleme geçiyor ve hata ancak çalışma anında ortaya çıkıyordu. Birimlerin (km/hilemiles/hgibi) yanlış aktarılmasında da aynı sorun vardı. Tipleri artırarak bu tür hataları geliştirme aşamasında yakalamak istiyordum ama o zaman junior’dım ve insanları ikna etmek zordu.Fiziksel birimler için tip uygulamasının fazla karmaşık olacağından vazgeçmiştim ama mp-units’e bakmayı planlıyorum. Özellikle değişkenin hangi birimde olduğunu açıkça göstermemek sık sık sorun çıkarıyor. Dış veriler ya da standart fonksiyonlar gibi yerlerde birim belirtilmemesi çok yaygın.
C#’ta şu şekilde tip tanımlıyorum:
Sonra
böylece farklı integer ID’leri birbirinden ayırabiliyorum. Bunu
IdGuidya daIdStringgibi türlere de genişletebilirsiniz ve yeni bir marker type (M) eklemek yalnızca bir satır sürer. TypeScript ve Rust’ta da benzer varyasyonlar kullanıyorum.Benzer bir kalıp kullanmıştım.
intID söz konusuysa friction’ı en düşük şey aslındaenum; ama fazla kafa karıştırıcı olabileceğini düşündüğüm için gerçek koda koymadım. İlgili tartışmaBu kalıba “phantom type” denir; çünkü
MFooya daMBardeğerleri runtime’da gerçekten var olmaz.Bu amaç için Vogen gibi kütüphaneler de var. Vogen, Value Object Generator anlamına geliyor ve source code generation ile value object tipleri eklemeyi destekliyor. README’sinde benzer kütüphaneler ve bağlantılar da bulunuyor.
Bu yaklaşımı daha önce de görmüştüm ama amacını bilmiyordum. Bugün de üç string argümanı alan bir fonksiyon yazarken tipi dışarıda parse etmeyi mi zorunlu kılsam, yoksa fonksiyon içinde mi yapsam diye düşünüyordum. Aslında parse edilmiş değere ihtiyacım yokmuş; demek ki aradığım cevap tam olarak buymuş. Muhtemelen bu yıl kodlama tarzımı en çok etkileyecek şey olacak.
Arkadaşım Lukas bu fikri “Safety Through Incompatibility” diye özetlemişti. Ben de bu kalıbı golang kodunun her yerine uyguladım ve çok faydalı buldum. Yanlış ID geçirilmesini en baştan engelliyor.
İlgili yazı 1
İlgili yazı 2
Swift’te
typealiasanahtar sözcüğü var ama temel tip aynıysa birbirine serbestçe dönüştürülebildikleri için pratikte bu amaç için uygun değil. Wrapper struct Swift’te daha deyimsel veExpressibleByStringLiteralile birlikte kullanıldığında fena değil. Yine de “güçlü tip takma adı” gibi yeni bir anahtar sözcük (typecopygibi) olsa ve “bu aslında sadece bir String ama özel anlam taşıyan bir String; diğer String’lerle karıştırma” demeyi mümkün kılsa güzel olurdu.Aslında çoğu dil bu şekilde davranıyor; örneğin rust/c/c++ da öyle. Go örneğinde olduğu gibi wrapper type yazmak zorunda olmadığınız durumlar hoş oluyor. C++’ta kurucuyu
explicitolarak işaretlemezsenizint’iFoobeklenen yere serbestçe verebildiğiniz için ekstra dikkat gerekiyor.Teoride zarif görünse de pratikte uygulaması karmaşık olabilir. C++’ta
std::coutile yazdırmak ya da eskidenStringalan üçüncü taraf fonksiyonlar veya extension point’lerle uyumluluk gibi konular gerçek hayatta düşünülmesi gereken şeyler.Haskell’de bunun karşılığı
newtype. OOP dillerinde tipfinaldeğilse, kolayca alt sınıf üretip istediğiniz davranışı ekleyebilir ya da özelleştirebilirsiniz. Ekstra wrapper ya da boxing olmadan ucuz ve basit olur. Ama Java’daStringfinalolduğu için bu yol zor;String’i özelleştirmek kolay değil.Somut olarak, bunun wrapper struct’tan hangi açıdan farklı çalışmasını isterdiniz?
Rust da zaten bu şekilde kullanılıyor; kesinlikle iyi bir yaklaşım gibi görünüyor.
Tip sistemi iyi olan bir dil kullansaydınız, bunun gibi şeyleri de önleyemez miydiniz?..
1999 Eylül'ünde NASA Mars Climate Orbiter'ın kaybolması