Sözleşmeli Programlama
Sözleşmeli programlama, işlevlerin hizmet sunan birimler olarak kabul edilmeleri düşüncesi üzerine kurulu bir programlama yöntemidir. Bu düşünceye göre, işlevler ve onları çağıran kodlar arasında yazısız bazı anlaşmalar vardır. Sözleşmeli programlama, bu anlaşmaları dil düzeyinde belirlemeye yarayan olanaktır.
Sözleşmeli programlama, ticari bir dil olan Eiffel tarafından "design by contract (DBC)" adıyla yayılmıştır. Bu yöntem D dilinde "contract programming" olarak geçer. Birim testlerinde olduğu gibi, assert
denetimlerine dayanır ve D'nin kod sağlamlığı sağlayan bir başka olanağıdır.
D'de sözleşmeli programlama üç temelden oluşur:
- İşlevlerin
in
blokları - İşlevlerin
out
blokları - Yapı ve sınıfların
invariant
blokları
invariant
bloklarını ve sözleşme kalıtımını ilerideki bir bölümde ve yapı ve sınıflardan daha sonra göreceğiz.
Giriş koşulları için in
blokları
İşlevlerin doğru çalışabilmeleri, aldıkları parametre değerlerine bağlı olabilir. Örneğin karekök alan bir işlev kendisine verilen parametrenin sıfırdan küçük olmamasını şart koşar; veya parametre olarak tarih bilgisi alan bir işlev ayın 1 ile 12 arasında olmasını şart koşar.
Bu tür koşulları daha önce assert
ve enforce
bölümünde görmüştük. İşlevlerin parametreleriyle ilgili olan assert
denetimleri işlevin tanımlandığı blok içinde yapılıyordu:
string zamanDizgisi(int saat, int dakika) { assert((saat >= 0) && (saat <= 23)); assert((dakika >= 0) && (dakika <= 59)); return format("%02s:%02s", saat, dakika); }
D'nin sözleşmeli programlama anlayışında işlevlerin giriş koşulları "giriş" anlamına gelen in
bloklarında denetlenir. Sözleşmeli programlama blokları kullanıldığı zaman, işlevin asıl bloğu da do
ile belirlenir:
import std.stdio; import std.string; string zamanDizgisi(int saat, int dakika) in { assert((saat >= 0) && (saat <= 23)); assert((dakika >= 0) && (dakika <= 59)); } do { return format("%02s:%02s", saat, dakika); } void main() { writeln(zamanDizgisi(12, 34)); }
Not: D'nin önceki sürümlerinde bu amaç için do
yerine body
anahtar sözcüğü kullanılırdı.
İşlevin in
bloğunun yararı, işlevin başlatılmasıyla ilgili olan denetimlerin bir arada ve ayrı bir blok içinde yapılmasıdır. Böylece assert
denetimleri işlevin asıl işlemlerinin arasına karışmamış olurlar. İşlevin içinde yine de gerektikçe assert
denetimleri kullanılabilir, ama giriş koşulları sözleşmeli programlama anlayışına uygun olarak in
bloğuna yazılırlar.
in
bloklarındaki kodlar programın çalışması sırasında işlevin her çağrılışında otomatik olarak işletilirler. İşlevin asıl işleyişi, ancak bu koşullar sağlandığında devam eder. Böylece işlevin geçersiz başlangıç koşulları ile çalışması ve programın yanlış sonuçlarla devam etmesi önlenmiş olur.
in
bloğundaki bir assert
denetiminin başarısız olması sözleşmeyi işlevi çağıran tarafın bozduğunu gösterir. İşlev sözleşmenin gerektirdiği şekilde çağrılmamış demektir.
Çıkış garantileri için out
blokları
İşlevin yaptığı kabul edilen sözleşmenin karşı tarafı da işlevin sağladığı garantilerdir. Örneğin belirli bir senedeki Şubat ayının kaç gün çektiği bilgisini döndüren bir işlevin çıkış garantisi, döndürdüğü değerin 28 veya 29 olmasıdır.
Çıkış garantileri, işlevlerin "çıkış" anlamına gelen out
bloklarında denetlenirler.
İşlevin dönüş değerinin özel bir ismi yoktur; bu değer return
ile isimsiz olarak döndürülür. Bu durum, dönüş değeriyle ilgili garantileri yazarken bir sorun doğurur: ismi olmayınca, dönüş değeriyle ilgili assert
denetimleri de yazılamaz.
Bu sorun out
anahtar sözcüğünden sonra verilen isimle halledilmiştir. Bu isim dönüş değerini temsil eder ve denetlenecek olan garantilerde bu isim kullanılır:
int şubattaKaçGün(int yıl) out (sonuç) { assert((sonuç == 28) || (sonuç == 29)); } do { return artıkYıl_mı(yıl) ? 29 : 28; }
Ben out
bloğunun parametresinin ismi olarak sonuç
yazmayı uygun buldum; siz dönüşDeğeri
gibi başka bir isim de verebilirsiniz. Hangi ismi kullanırsanız kullanın, o isim işlevin dönüş değerini temsil eder.
Bazen işlevin dönüş değeri yoktur, veya dönüş değerinin denetlenmesi gerekmiyordur. O zaman out
bloğu parametresiz olarak yazılır:
out { // ... }
İşleve girerken in
bloklarının otomatik olarak işletilmeleri gibi, out
blokları da işlevden çıkarken otomatik olarak işletilirler.
out
bloğundaki bir assert
denetiminin başarısız olması sözleşmenin işlev tarafından bozulduğunu gösterir. İşlev sözleşmenin gerektirdiği değeri veya yan etkiyi üretememiş demektir.
Daha önceki bölümlerde hiç kullanmamış olduğumuzdan da anlaşılabileceği gibi, in
ve out
bloklarının kullanımı seçime bağlıdır. Bunlara yine seçime bağlı olan unittest
bloklarını da eklersek, D'de işlevler dört blok halinde yazılabilirler:
- Giriş koşulları için
in
bloğu: seçime bağlıdır ve giriş koşullarını denetler - Çıkış garantileri için
out
bloğu: seçime bağlıdır ve çıkış garantilerini denetler - İşlevin asıl işlemlerini içeren
do
bloğu: bu bloğun yazılması şarttır, ama eğerin
veout
blokları kullanılmamışsado
anahtar sözcüğü yazılmayabilir - İşlevin birim testlerini içeren
unittest
bloğu: bu aslında işlevin parçası değildir ve kendi başına işlev gibi yazılır; ama denetlediği işlevin hemen altına yazılması, aralarındaki bağı gösterme bakımından uygun olur
Bütün bu blokları içeren bir işlev tanımı şöyle yazılabilir:
import std.stdio; /* Toplamı iki parça olarak bölüştürür. * * Toplamdan öncelikle birinciye verir, ama birinciye hiçbir * zaman 7'den fazla vermez. Gerisini ikinciye verir. */ void bölüştür(int toplam, out int birinci, out int ikinci) in { assert(toplam >= 0, "toplam sıfırdan küçük olamaz"); } out { assert(toplam == (birinci + ikinci)); } do { birinci = (toplam >= 7) ? 7 : toplam; ikinci = toplam - birinci; } unittest { int birinci; int ikinci; // Toplam 0 ise ikisi de 0 olmalı bölüştür(0, birinci, ikinci); assert(birinci == 0); assert(ikinci == 0); // Toplam 7'den az ise birincisi toplam'a, ikincisi 0'a // eşit olmalı bölüştür(3, birinci, ikinci); assert(birinci == 3); assert(ikinci == 0); // Sınır koşulunu deneyelim bölüştür(7, birinci, ikinci); assert(birinci == 7); assert(ikinci == 0); // 7'den fazla olduğunda birinci 7 olmalı, gerisi ikinciye // gitmeli bölüştür(8, birinci, ikinci); assert(birinci == 7); assert(ikinci == 1); // Bir tane de büyük bir değerle deneyelim bölüştür(1_000_007, birinci, ikinci); assert(birinci == 7); assert(ikinci == 1_000_000); } void main() { int birinci; int ikinci; bölüştür(123, birinci, ikinci); writeln("birinci: ", birinci, " ikinci: ", ikinci); }
Program aşağıdaki gibi derlenebilir ve çalıştırılabilir:
$ dmd deneme.d -w -unittest
$ ./deneme
birinci: 7 ikinci: 116
Bu işlevin asıl işi yalnızca 2 satırdan oluşuyor; denetleyen kodlar ise tam 19 satır! Bu kadar küçük bir işlev için bu kadar emeğin gereksiz olduğu düşünülebilir. Ama dikkat ederseniz, programcı hiçbir zaman bilerek hatalı kod yazmaz. Programcının yazdığı kod her zaman için doğru çalışacak şekilde yazılmıştır. Buna rağmen, hatalar da hep böyle doğru çalışacağı düşünülen kodlar arasından çıkar.
İşlevlerden beklenenlerin birim testleri ve sözleşmeli programlama ile böyle açıkça ortaya koyulmaları, doğru olarak yazdığımız işlevlerin her zaman için doğru kalmalarına yardım eder. Program hatalarını azaltan hiçbir olanağı küçümsememenizi öneririm. Birim testleri ve sözleşmeli programlama olanakları bizi zorlu hatalardan koruyan çok etkili araçlardır. Böylece zamanımızı hata ayıklamak yerine, ondan çok daha zevkli ve verimli olan kod yazmaya ayırabiliriz.
Sözleşme ifadeleri
in
ve out
bloklarının her çeşit D kodu içerebilmesi doğal olarak yararlı bir olanaktır. Buna rağmen, giriş koşulları ve çıkış garantileri çoğu zaman basit assert
ifadelerinden oluşurlar. Böyle durumlarda bir kolaylık olarak sözleşme ifadelerinden yararlanılabilir. Aşağıdaki işleve bakalım:
int işlev(int a, int b) in { assert(a >= 7, "a 7'den küçük olamaz"); assert(b < 10); } out (sonuç) { assert(sonuç > 1000); } do { // ... }
Sözleşme ifadeleri blok parantezlerini ortadan kaldırır, assert
açıkça çağrılmaz, ve do
anahtar sözcüğüne gerek kalmaz:
int işlev(int a, int b) in (a >= 7, "a 7'den küçük olamaz") in (b < 10) out (sonuç; sonuç > 1000) { // ... }
İşlevin dönüş değerinin out
ifadesindeki noktalı virgülden önce isimlendirildiğine dikkat edin. İşlevin dönüş değeri bulunmadığında veya çıkış garantisi dönüş değeri ile ilgili olmadığında bu noktalı virgül yine de yazılmalıdır:
out (; /* ... */)
Sözleşmeli programlamayı etkisizleştirmek
Birim testlerinin tersine, sözleşmeli programlama normalde etkilidir; etkisizleştirmek için özel bir derleyici veya geliştirme ortamı ayarı gerekir. Bunun için dmd
derleyicisinde -release
seçeneği kullanılır:
dmd deneme.d -w -release
Program o seçenekle derlendiğinde in
, out
, ve invariant
blokları programa dahil edilmezler.
in
bloğu mu enforce
mu
assert
ve enforce
bölümünde karşılaştığımız assert
ile enforce
arasındaki karar güçlüğü in
blokları ile enforce()
arasında da vardır. in
bloğundaki assert
denetimlerinin mi yoksa işlev tanımı içindeki enforce
denetimlerinin mi daha uygun olduğuna karar vermek bazen güç olabilir.
Yukarıda gördüğümüz gibi, sözleşmeli programlama bütünüyle etkisizleştirilebilir. Bundan da anlaşılabileceği gibi, sözleşmeli programlama da assert
ve unittest
gibi programcı hatalarına karşı koruma getiren bir olanaktır.
Bu yüzden işlevlerin giriş koşulu denetimlerinin hangi yöntemle sağlanacağının kararı da yine assert
ve enforce
bölümünde gördüğümüz maddelerle verilebilir:
- Eğer denetim programın kendisi ile ilgili ise, yani programcının olası hatalarına karşı koruma getiriyorsa
in
bloklarındakiassert
denetimleri kullanılmalıdır. Örneğin, işlev yalnızca programın kendi işlemleri için çağırdığı bir yardımcı işlevse, o işlevin giriş koşullarını sağlamak bütünüyle programı yazan programcının sorumluluğunda demektir. O yüzden böyle bir işlevin giriş koşullarının denetimiin
bloklarında yapılmalıdır. - Herhangi bir işlem başka bazı koşullar sağlanmadığı için gerçekleştirilemiyorsa
enforce
ile hata atılmalıdır.Bunun bir örneğini görmek için bir dilimin en ortasını yine bir dilim olarak döndüren bir işleve bakalım. Bu işlev bir kütüphaneye ait olsun; yani, belirli bir modülün özel bir yardımcı işlevi değil, bir kütüphanenin arayüzünün bir parçası olsun. Kullanıcılar böyle bir işlevi doğru veya yanlış her türlü parametre değeriyle çağırabilecekleri için bu işlevin giriş koşullarının her zaman için denetlenmesi gerekecektir.
O yüzden aşağıdaki işlevde
in
bloğundakiassert
denetimlerinden değil, işlevin tanımındaki birenforce
'tan yararlanılmaktadır. Yoksain
bloğu kullanılmış olsa, sözleşmeli programlama etkisizleştirildiğinde böyle bir denetimin ortadan kalkması güvensiz olurdu.import std.exception; inout(int)[] ortadakiler(inout(int)[] asılDilim, size_t uzunluk) out (sonuç) { assert(sonuç.length == uzunluk); } do { enforce(asılDilim.length >= uzunluk); immutable baş = (asılDilim.length - uzunluk) / 2; immutable son = baş + uzunluk; return asılDilim[baş .. son]; } unittest { auto dilim = [1, 2, 3, 4, 5]; assert(ortadakiler(dilim, 3) == [2, 3, 4]); assert(ortadakiler(dilim, 2) == [2, 3]); assert(ortadakiler(dilim, 5) == dilim); } void main() { }
out
blokları ile ilgili buna benzer bir karar güçlüğü yoktur. Her işlev döndürdüğü değerden kendisi sorumlu olduğundan ve bir anlamda dönüş değeri programcının sorumluluğunda olduğundan çıkış denetimleri her zaman içinout
bloklarına yazılmalıdır. Yukarıdaki işlev buna uygun olarakout
bloğundan yararlanıyor. in
blokları veenforce
arasında karar verirken başka bir kıstas, karşılaşılan durumun giderilebilen bir hata çeşidi olup olmadığıdır. Eğer giderilebilen bir durumsa hata atmak uygun olabilir. Böylece daha üst düzeydeki bir işlev atılan bu hatayı yakalayabilir ve hatanın türüne göre farklı davranabilir.
Problem
İki futbol takımının puanlarını bir maçın sonucuna göre arttıran bir işlev yazın.
Bu işlevin ilk iki parametresi birinci ve ikinci takımın attıkları goller olsun. Son iki parametresi de bu takımların maçtan önceki puanları olsun. Bu işlev golleri dikkate alarak birinci ve ikinci takımın puanlarını düzenlesin: fazla gol atan taraf üç puan kazansız, goller eşitse iki takım da birer puan kazansınlar.
Ek olarak, işlevin dönüş değeri de kazanan tarafı belirtsin: birinci kazanmışsa 1, ikinci kazanmışsa 2, berabere kalmışlarsa 0.
Aşağıdaki programla başlayın ve işlevin dört bloğunu uygun şekilde doldurun. Benim main
içine yazdığım assert
denetimlerini silmeyin. Onlar benim bu işlevin çalışması konusundaki beklentilerimi belgeliyorlar.
int puanEkle(int goller1, int goller2, ref int puan1, ref int puan2) in { // ... } out (sonuç) { // ... } do { int kazanan; // ... return kazanan; } unittest { // ... } void main() { int birincininPuanı = 10; int ikincininPuanı = 7; int kazananTaraf; kazananTaraf = puanEkle(3, 1, birincininPuanı, ikincininPuanı); assert(birincininPuanı == 13); assert(ikincininPuanı == 7); assert(kazananTaraf == 1); kazananTaraf = puanEkle(2, 2, birincininPuanı, ikincininPuanı); assert(birincininPuanı == 14); assert(ikincininPuanı == 8); assert(kazananTaraf == 0); }
Ben int
seçtiğim halde burada üç değerli bir enum
türü döndürmek daha uygun olurdu:
enum MaçSonucu { birinciKazandı, ikinciKazandı, berabere } MaçSonucu puanEkle(int goller1, int goller2, ref int puan1, ref int puan2) // ...