D Programlama Dili – Programlama dersleri ve D referansı
Ali Çehreli

algoritma: [algorithm], verilerin işlenme adımları, işlev
aralık: [range], belirli biçimde erişilen bir grup eleman
bağlı liste: [linked list], her elemanı bir sonraki elemanı gösteren veri yapısı
değişken: [variable], kavramları temsil eden veya sınıf nesnesine erişim sağlayan program yapısı
hevesli: [eager], işlemlerin, ürettikleri sonuçların kullanılacaklarından emin olunmadan gerçekleştirilmeleri
işlev: [function], programdaki bir kaç adımı bir araya getiren program parçası
Phobos: [Phobos], D dilinin standart kütüphanesi
sağ değer: [rvalue], adresi alınamayan değer
sol değer: [lvalue], adresi alınabilen değer
tembel değerlendirme: [lazy evaluation], işlemlerin gerçekten gerekene kadar geciktirilmesi
temsilci: [delegate], oluşturulduğu ortamdaki değişkenlere erişebilen isimsiz işlev
topluluk: [container], aynı türden birden fazla veriyi bir araya getiren veri yapısı
veri yapıları: [data structures], verilerin bilgisayar biliminin tanımladığı biçimde saklanmaları ve işlenmeleri
... bütün sözlük



İngilizce Kaynaklar


Diğer




Aralıklar

Aralıklar, topluluk elemanlarına erişim işlemini soyutlarlar. Bu soyutlama, çok sayıdaki veri yapısının çok sayıdaki algoritma ile uyumlu olarak kullanılmasını sağlar. Veri yapılarının nasıl gerçekleştirilmiş oldukları önemsizleşir, elemanlarına nasıl erişildiği ön plana çıkar.

Aralıklar, türlerin belirli isimdeki işlevleri sunmaları ilkesi üzerine kurulu olan aslında çok basit bir kavramdır. Bu kavramla daha önce Yapı ve Sınıflarda foreach bölümünde de karşılaşmıştık: empty, front ve popFront() üye işlevlerini tanımlayan her tür, foreach döngüsü ile kullanılabiliyordu. O üç işlev, InputRange aralık çeşidinin gerektirdiği işlevlerdir.

Aralıklarla ilgili kavramları en basit aralık çeşidi olan InputRange ile göstereceğim. Diğer aralıkların farkları, başka işlevler de gerektirmeleridir.

Daha ileri gitmeden önce aralıklarla doğrudan ilgili olan topluluk ve algoritma tanımlarını hatırlatmak istiyorum.

Topluluk (veri yapısı): Topluluk, neredeyse bütün programlarda karşılaşılan çok yararlı bir kavramdır. Değişkenler belirli amaçlarla bir araya getirilirler ve sonradan bir topluluğun elemanları olarak kullanılırlar. D'deki topluluklar; diziler, eşleme tabloları, ve std.container modülünde tanımlanmış olan topluluk türleridir. Her topluluk belirli bir veri yapısı olarak gerçekleştirilir. Örneğin eşleme tabloları bir hash table veri yapısı gerçekleştirmesidir.

Her veri yapısı türü, elemanları o veri yapısına özel biçimde barındırır ve elemanlara o veri yapısına özel biçimde eriştirir. Örneğin dizi veri yapısında elemanlar yan yana dururlar ve sıra numarası ile erişilirler; bağlı liste yapısında elemanlar düğümlerde saklanırlar ve bu düğümler aracılığıyla erişilirler; ikili ağaç veri yapısında düğümler kendilerinden sıralamada önceki ve sonraki elemanlara farklı dallar yoluyla erişim sağlarlar; vs.

Ben bu bölümde topluluk ve veri yapısı deyimlerini aynı anlamda kullanacağım.

Algoritma (işlev): Veri yapılarının belirli amaçlarla ve belirli adımlar halinde işlenmelerine algoritma denir. Örneğin sıralı arama algoritması, aranan değeri topluluktaki bütün elemanları başından sonuna kadar ilerleyerek arayan bir algoritmadır; ikili arama algoritması, her adımda elemanların yarısını eleyerek arayan bir algoritmadır; vs.

Ben bu bölümde algoritma ve işlev deyimlerini aynı anlamda kullanacağım.

Aşağıdaki çoğu örnekte eleman türü olarak int, topluluk türü olarak da int[] kullanacağım. Aslında aralıkların gücü şablonlarla birlikte kullanıldıklarında ortaya çıkar. Aralıkların birbirlerine uydurduğu çoğu topluluk ve çoğu algoritma şablondur. Bunların örneklerini bir sonraki bölüme bırakacağım.

Tarihçe

Algoritmalarla veri yapılarını birbirlerinden başarıyla soyutlayan bir kütüphane, C++ dilinin standart kütüphanesinin de bir parçası olan STL'dir (Standard Template Library). STL bu soyutlamayı C++'ın şablon olanağından yararlanarak gerçekleştirdiği erişici (iterator) kavramı ile sağlar.

Çok güçlü bir soyutlama olmasına rağmen erişici kavramının bazı zayıflıkları da vardır. Aralıklar, erişicilerin bu zayıflıklarını gidermeye yönelik olarak tasarlanmıştır.

Andrei Alexandrescu, Eleman Erişimi Üzerine isimli makalesinde aralıkları tanıtır ve aralıkların erişicilerden nasıl daha üstün olabildiklerini gösterir.

Aralıklar D'de kaçınılmazdır

D dilimleri en işlevsel aralık çeşidi olan RandomAccessRange'e uyarlar ve Phobos, aralıklarla ilgili çok sayıda olanak içerir. Çoğu programda kendi aralık türlerimizi veya aralık işlevlerimizi yazmamız gerekmez. Buna rağmen aralıkların Phobos'ta nasıl kullanıldığını bilmek önemlidir.

Phobos'taki çok sayıda algoritma, kullanımları sırasında farkedilmese bile aslında geçici aralık nesneleri döndürürler. Örneğin elemanların 10'dan büyük olanlarını seçmek için kullanılan aşağıdaki filter() dizi değil, aralık nesnesi döndürür:

import std.stdio;
import std.algorithm;

void main() {
    int[] sayılar = [ 1, 20, 7, 11 ];
    writeln(sayılar.filter!(sayı => sayı > 10));
}

writeln, filter()'ın döndürmüş olduğu aralık nesnesini gerektikçe tembel olarak kullanır. Sonuçta, belirtilen kıstasa uyan elemanlar yazdırılırlar:

[20, 11]

O sonuca bakarak filter()'ın int dizisi döndürdüğü düşünülebilir; ancak bu doğru değildir. Döndürülen nesne bir dizi olmadığı için örneğin aşağıdaki satır derlenemez:

    int[] seçilenler = sayılar.filter!(sayı => sayı > 10); // ← derleme HATASI

Döndürülen nesnenin türünü hata mesajında görüyoruz:

Error: cannot implicitly convert expression (filter(sayılar))
of type FilterResult!(__lambda2, int[]) to int[]

Not: O tür sizin denediğiniz Phobos sürümünde farklı olabilir.

O geçici aralık nesnesinin istendiğinde bir diziye de dönüştürülebileceğini aşağıda göstereceğim.

Algoritmaların geleneksel gerçekleştirmeleri

Geleneksel olarak algoritmalar işlemekte oldukları veri yapılarının nasıl gerçekleştirildiklerini bilmek zorundadırlar. Örneğin bir bağlı listenin elemanlarını sırayla çıkışa yazdıran aşağıdaki işlev, kullandığı bağlı listenin düğümlerinin eleman ve sonraki isminde iki üyesi bulunduğunu bilmek zorundadır:

struct Düğüm {
    int eleman;
    Düğüm * sonraki;
}

void yazdır(const(Düğüm) * liste) {
    for ( ; liste; liste = liste.sonraki) {
        write(' ', liste.eleman);
    }
}

Benzer şekilde, bir diziyi yazdıran işlev de dizilerin length isminde niteliklerinin bulunduğunu ve elemanlarına [] işleci ile erişildiğini bilmek zorundadır:

void yazdır(const int[] dizi) {
    for (int i = 0; i != dizi.length; ++i) {
        write(' ', dizi[i]);
    }
}

Not: Dizilerde ilerlerken foreach'in daha uygun olduğunu biliyoruz. Amacım algoritmaların geleneksel olarak veri yapılarına doğrudan bağlı olduklarını göstermek olduğu için, for'un gerçekten gerektiği bir durum olduğunu kabul edelim.

Algoritmaların veri yapılarına bu biçimde bağlı olmaları, onların her veri yapısı için özel olarak yazılmalarını gerektirir. Örneğin; dizi, bağlı liste, eşleme tablosu, ikili ağaç, yığın, vs. gibi veri yapılarının her birisi için ara(), sırala(), ortakOlanlarınıBul(), değiştir(), vs. gibi algoritmaların ayrı ayrı yazılmaları gerekir. Bunun sonucunda da A adet algoritmanın V adet veri yapısı ile kullanılabilmesi için gereken işlev sayısı A çarpı V'dir. (Not: Her algoritma her veri yapısı ile kullanılamadığı için gerçekte bu sayı daha düşüktür. Örneğin eşleme tabloları sıralanamazlar.)

Öte yandan, aralıklar veri yapılarıyla algoritmaları birbirlerinden soyutladıkları için yalnızca A adet algoritma ve V adet veri yapısı yazmak yeterli olur. Yeni yazılan bir veri yapısı, onun sunduğu aralık çeşidini destekleyen bütün algoritmalarla kullanılmaya hazırdır; yeni yazılan bir algoritma da onun gerektirdiği aralık çeşidine uyan bütün veri yapıları ile işlemeye hazırdır.

Phobos aralıkları

Bu bölümün konusu olan aralıklar, baş..son biçiminde yazılan sayı aralıklarından farklıdır. Sayı aralıklarını foreach döngüsündeki ve dilimlerdeki kullanımlarından tanıyoruz:

    int[] dilim = dizi[5..10];   // sayı aralığı,
                                 // Phobos aralığı DEĞİL

    foreach (sayı; 3..7) {       // sayı aralığı,
                                 // Phobos aralığı DEĞİL

Ben bu bölümde aralık yazdığım yerlerde Phobos aralıklarını kastedeceğim.

Aralıklar bir aralık sıradüzeni oluştururlar. Bu sıradüzen en basit aralık olan InputRange ile başlar. Diğer aralıklar, temel aldıkları aralığın gerektirdiği işlevlere ek olarak başka işlevler de gerektirirler. Aralık çeşitleri, en temelden en işlevsele doğru ve gerektirdikleri işlevlerle birlikte şunlardır:

Bu sıradüzeni aşağıdaki gibi gösterebiliriz. RandomAccessRange, sonlu ve sonsuz olarak iki çeşittir:

                    InputRange
                  (giriş aralığı)
                        ↑
                   ForwardRange
                (ilerleme aralığı)
                   ↗         ↖
     BidirectionalRange     RandomAccessRange (sonsuz)
     (çift uçlu aralık)     (rastgele erişimli aralık)
             ↑
  RandomAccessRange (sonlu)
  (rastgele erişimli aralık)

Yukarıdaki aralıklar eleman erişimine yönelik aralıklardır. Onlara ek olarak eleman çıkışı ile ilgili olan bir aralık daha vardır:

Bu beş aralık, algoritmaların veri yapılarından soyutlanmaları için yeterlidir.

Aralığı daraltarak ilerlemek

Şimdiye kadar çoğu örnekte kullandığımız ilerleme yönteminde aralığın kendi durumunda değişiklik olmaz. Örneğin bir dilimde foreach veya for ile ilerlendiğinde dilimin kendisi değişmez:

    int[] dilim = [ 10, 11, 12 ];

    for (int i = 0; i != dilim.length; ++i) {
        write(' ', dilim[i]);
    }

    assert(dilim.length == 3);    // uzunluğu değişmez

Burada, salt ilerleme işleminin dilimde bir değişiklik oluşturmadığını belirtmek istiyorum.

Farklı bir bakış açısı getiren bir yöntem, aralığı başından daraltarak ilerlemektir. Bu yöntemde aralığın hep ilk elemanına erişilir. İlerleme, her seferinde baştaki eleman çıkartılarak sağlanır:

    for ( ; dilim.length; dilim = dilim[1..$]) {
        write(' ', dilim[0]);     // hep ilk elemana erişilir
    }

Yukarıdaki döngünün ilerlemesi, dilim = dilim[1..$] ifadesinin baştaki elemanı dilimden çıkartması ile sağlanmaktadır. Dilim, o ifadenin etkisiyle aşağıdaki aşamalardan geçerek daralır ve sonunda boşalır:

[ 10, 11, 12 ]
    [ 11, 12 ]
        [ 12 ]
           [ ]

İşte Phobos aralıklarındaki ilerleme kavramı, aralığı bu şekilde başından daraltma düşüncesi üzerine kuruludur. (BidirectionalRange ve sonlu RandomAccessRange aralıkları son taraftan da daralabilirler.)

O örneği yalnızca bu tür ilerleme kavramını göstermek için verdim; for döngülerinin o şekilde yazılması normal kabul edilmemelidir.

Salt ilerlemiş olmak için elemanların dilimden bu şekilde çıkartılmaları çoğu durumda istenmeyeceğinden; asıl topluluğun kendisi değil, yalnızca ilerlemek için oluşturulan başka bir aralık tüketilir. Bu örnekteki asıl dilimi korumak için örneğin başka bir dilimden yararlanılabilir:

    int[] dilim = [ 10, 11, 12 ];
    int[] dilim2 = dilim;

    for ( ; dilim2.length; dilim2 = dilim2[1..$]) {
        write(' ', dilim2[0]);
    }

    assert(dilim2.length == 0);   // ← dilim2 boşalır
    assert(dilim.length == 3);    // ← dilim değişmez

Phobos işlevleri de asıl topluluğun değişmemesi için özel aralık nesneleri döndürürler.

InputRange, giriş aralığı

Bu çeşit aralık, yukarıdaki geleneksel yazdır() işlevlerinde de olduğu gibi elemanların art arda erişildikleri aralık çeşidini ifade eder. Bu erişim hep ileri yöndedir; tekrar başa dönülemez. Buna rağmen, çok sayıda algoritma yalnızca InputRange kullanarak yazılabilir; çünkü çoğu algoritma yalnızca ileri yönde ilerleme üzerine kuruludur. Programların standart girişlerinde olduğu gibi, okundukça elemanların tüketildikleri akımlar da bu tür aralık tanımına girerler.

InputRange aralıklarının gerektirdiği üç işlevi bütünlük amacıyla bir kere daha hatırlatıyorum:

Not: empty ve front işlevlerini nitelik olarak kullanılmaya uygun oldukları için parantezsiz, popFront() işlevini ise yan etkisi olan bir işlev olduğu için parametre listesi ile yazmaya karar verdim.

yazdır() işlevini bir kere de bu üç işlevden yararlanacak şekilde gerçekleştirelim:

void yazdır(T)(T aralık) {
    for ( ; !aralık.empty; aralık.popFront()) {
        write(' ', aralık.front);
    }

    writeln();
}

Aralığın elemanlarının türü konusunda bir kısıtlama getirmiş olmamak için işlevi ayrıca şablon olarak tanımladığıma dikkat edin. yazdır() böylece topluluğun asıl türünden de bağımsız hale gelir ve InputRange'in gerektirdiği üç işlevi sunan her toplulukla kullanılabilir.

Bir InputRange örneği

Daha önce de karşılaşmış olduğumuz Okul türünü InputRange tanımına uygun olarak tekrar tasarlayalım. Okul'u bir Öğrenci topluluğu olarak düşünelim ve onu elemanlarının türü Öğrenci olan bir aralık olarak tanımlamaya çalışalım.

Örneği kısa tutmuş olmak için bazı önemli konularla ilgilenmeyeceğim:

import std.string;

struct Öğrenci {
    string isim;
    int numara;

    string toString() const {
        return format("%s(%s)", isim, numara);
    }
}

struct Okul {
    Öğrenci[] öğrenciler;
}

void main() {
    auto okul = Okul( [ Öğrenci("Ebru", 1),
                        Öğrenci("Derya", 2) ,
                        Öğrenci("Damla", 3) ] );
}

Okul türünü bir InputRange olarak kullanabilmek için, InputRange'in gerektirdiği üç üye işlevi tanımlamamız gerekiyor.

empty işlevinin aralık boş olduğunda true döndürmesini sağlamak için doğrudan öğrenciler dizisinin uzunluğunu kullanabiliriz. Dizinin uzunluğu 0 olduğunda aralık da boş kabul edilmelidir:

struct Okul {
    // ...

    bool empty() const {
        return öğrenciler.length == 0;
    }
}

front işlevinin aralıktaki ilk elemanı döndürmesi, dizinin ilk elemanı döndürülerek sağlanabilir:

struct Okul {
    // ...

    ref Öğrenci front() {
        return öğrenciler[0];
    }
}

Not: Dizideki asıl elemana erişim sağlamış olmak için ref dönüş türü kullandığımıza dikkat edin. Öyle yazmasaydık, Öğrenci bir yapı türü olduğu için ilk elemanın kopyası döndürülürdü.

popFront() işlevinin aralığı başından daraltması, öğrenciler dizisini başında daraltarak sağlanabilir:

struct Okul {
    // ...

    void popFront() {
        öğrenciler = öğrenciler[1 .. $];
    }
}

Not: Yukarıda da değindiğim gibi, salt ilerlemiş olmak için aralıktan öğrenci çıkartılıyor olması çoğu duruma uygun değildir. Bu sorunu daha sonra özel bir aralık türü yardımıyla gidereceğiz.

Bu üç işlev Okul türünün InputRange olarak kullanılması için yeterlidir. Okul nesnelerini artık başka hiçbir şey gerekmeden örneğin yazdır() şablonuna gönderebiliriz:

    yazdır(okul);

yazdır(), InputRange tanımına uyan Okul'u aralık işlevleri aracılığıyla kullanır. Sonuçta aralığın elemanları teker teker çıkışa yazdırılırlar:

 Ebru(1) Derya(2) Damla(3)

Böylece kendi yazdığımız bir türü InputRange tanımına uydurmuş ve InputRange'lerle işleyen bir işleve gönderebilmiş olduk. Okul, Phobos veya başka kütüphanelerin InputRange alan algoritmalarıyla da kullanılmaya hazırdır. Bunu biraz aşağıda göreceğiz.

Dilimleri aralık olarak kullanabilmek için std.array modülü

En sık kullanılan topluluk çeşidi olan dilimler, en işlevsel aralık çeşidi olan RandomAccessRange olarak kullanılabilirler. Bunun için std.array modülünün eklenmesi yeterlidir.

std.array modülü; empty, front, popFront() ve diğer aralık işlevlerini dilimler için özel olarak tanımlar. Böylece dilimler örneğin yazdır() işlevine gönderilmeye hazırdırlar:

import std.array;

// ...

    yazdır([ 1, 2, 3, 4 ]);

Not: Biraz aşağıda göreceğimiz std.range modülü eklendiğinde std.array'in ayrıca eklenmesine gerek yoktur.

Sabit uzunluklu dizilerden eleman çıkartılması mümkün olmadığından popFront() onlar için tanımlanamaz. Bu yüzden sabit uzunluklu diziler kendileri aralık olarak kullanılamazlar:

void yazdır(T)(T aralık) {
    for ( ; !aralık.empty; aralık.popFront()) { // ← derleme HATASI
        write(' ', aralık.front);
    }

    writeln();
}

void main() {
    int[4] dizi = [ 1, 2, 3, 4 ];
    yazdır(dizi);
}

Not: Derleme hatasının yazdır()'ın çağrıldığı satırda oluşması hatanın kaynağını göstermesi açısından daha yararlı olurdu. Bunun için yazdır()'a bir sonraki bölümde göreceğimiz isInputRange'den yararlanan bir şablon kısıtlaması eklenebilir.

void yazdır(T)(T aralık)
        if (isInputRange!T) {    // ← şablon kısıtlaması
    // ...
}
// ...
    yazdır(dizi);    // ← derleme HATASI

Sabit uzunluklu bir dizinin elemanlarına aralık işlevleriyle erişmek yine de mümkündür. Yapılması gereken, dizinin kendisini değil, bütün diziye erişim sağlayan bir dilim kullanmaktır:

    yazdır(dizi[]);    // şimdi derlenir

Her dilimin aralık olarak kullanılabilmesinin aksine, aralıklar dizi olarak kullanılamazlar. Aralık elemanlarından dizi oluşturmak gerektiğinde elemanlar teker teker açıkça kopyalanmalıdır. Bunun için std.array.array işlevi kullanılabilir. array(), InputRange aralığını başından sonuna kadar ilerler, her elemanı kopyalar, ve yeni bir dizi döndürür:

import std.array;

// ...

    // Not: UFCS'ten de yararlanılıyor
    auto öğrencilerinKopyaları = okul.array;
    writeln(öğrencilerinKopyaları);

Çıktısı:

[Ebru(1), Derya(2), Damla(3)]

Kodda UFCS'ten de yararlanıldığına dikkat edin. UFCS kodun yazımı ile işleyişini birbirine uygun hale getirdiğinden özellikle aralık algoritmalarında çok yararlanılan bir olanaktır.

Dizgilerin dchar aralığına dönüşmeleri

Tanım gereği olarak zaten karakter dizisi olan dizgiler de std.array modülü sayesinde hemen hemen bütün aralık çeşitleri olarak kullanılabilirler. Bunun istisnaları, char ve wchar dizgilerinin RandomAccessRange tanımına giremiyor olmalarıdır.

Ancak, std.array modülünün dizgilere özel önemli bir yararı daha vardır: Dizgilerde ileri veya geri yönde ilerlendiğinde elemanlara UTF kod birimleri olarak değil, Unicode karakterleri olarak erişilir. Bunun anlamı, ne tür dizgi olursa olsun dizgi elemanlarının harf harf ilerlenmesidir.

Aşağıdaki dizgilerde char'a sığmadıklarını bildiğimiz ç ve ğ harflerinden başka wchar'a sığmayan çift çizgili matematik A harfi (𝔸) de bulunuyor. Bu ortamda desteklenmiyorsa bir soru işareti olarak görünüyor olabilir:

import std.array;

// ...

    yazdır("abcçdefgğ𝔸"c);
    yazdır("abcçdefgğ𝔸"w);
    yazdır("abcçdefgğ𝔸"d);

Buna rağmen, programın çıktısı çoğu durumda zaten istemiş olacağımız gibidir:

 a b c ç d e f g ğ 𝔸
 a b c ç d e f g ğ 𝔸
 a b c ç d e f g ğ 𝔸

Bu çıktının Karakterler ve Dizgiler bölümlerinde gördüğümüz davranışlara uymadığına dikkat edin. Hatırlarsanız, char ve wchar dizgilerinin elemanları UTF kod birimleridir.

Yukarıdaki çıktılarda kod birimleri yerine Unicode karakterlerinin belirmesinin nedeni, aralık olarak kullanıldıklarında dizgilerin elemanlarının otomatik olarak Unicode karakterlerine dönüştürülmeleridir. Aşağıda göreceğimiz gibi, Unicode karakteri olarak beliren dchar değerleri dizgilerin asıl elemanları değil, onlardan oluşturulan sağ değerlerdir.

Bunu hatırlamak için dizgilerin elemanlarını tek tek indeksleyerek yazdıralım:

void elemanlarınıYazdır(T)(T dizgi) {
    for (int i = 0; i != dizgi.length; ++i) {
        write(' ', dizgi[i]);
    }

    writeln();
}

// ...

    elemanlarınıYazdır("abcçdefgğ𝔸"c);
    elemanlarınıYazdır("abcçdefgğ𝔸"w);
    elemanlarınıYazdır("abcçdefgğ𝔸"d);

Doğrudan dizgi elemanlarına erişildiğinde Unicode harflerine değil, UTF kod birimlerine erişilmiş olunur:

 a b c � � d e f g � � � � � �
 a b c ç d e f g ğ ��� ���
 a b c ç d e f g ğ 𝔸

Bu otomatik dönüşüm her duruma uygun değildir. Örneğin, bir dizginin ilk elemanına atamaya çalışan aşağıdaki program derlenemez çünkü .front'un dönüş değeri bir sağ değerdir:

import std.array;

void main() {
    char[] s = "merhaba".dup;
    s.front = 'M';                   // ← derleme HATASI
}
Error: front(s) is not an lvalue

Bir aralık algoritması dizginin asıl elemanlarını değiştirmek istediğinde (ve bu değişikliğin dizginin UTF kodlamasını bozmayacağı bir durumda), std.string.represention çağrılarak dizgi bir ubyte aralığı olarak kullanılabilir:

import std.array;
import std.string;

void main() {
    char[] s = "merhaba".dup;
    s.representation.front = 'M';    // derlenir
    assert(s == "Merhaba");
}

representation; char, wchar, ve dchar dizgilerinin asıl elemanlarını sırasıyla ubyte, ushort, ve uint aralıkları olarak sunar.

Kendi elemanları bulunmayan aralıklar

Yukarıda aralık örneği olarak kullandığımız dizilerde ve Okul nesnelerinde hep gerçek elemanlar bulunuyordu. Örneğin Okul.front, var olan bir Öğrenci nesnesine referans döndürüyordu.

Aralıkların bir üstünlüğü, bu konuda da esneklik getirmeleridir: front'un döndürdüğü elemanın bir topluluğun gerçek bir elemanı olması gerekmez. O sözde eleman, örneğin popFront() her çağrıldığında hesaplanarak oluşturulabilir ve front her çağrıldığında döndürülebilir.

Gerçek elemanları bulunmayan bir aralık örneğiyle aslında biraz yukarıda da karşılaştık: Dizgiler aralık olarak kullanıldıklarında UTF kod birimlerine değil, Unicode karakterlerine erişildiğini gördük. Oysa; char ve wchar Unicode karakteri ifade edemeyeceklerinden, aralık olarak kullandığımızda elde edilen Unicode karakterleri o dizgilerin gerçek elemanları olamazlar. front'un döndürdüğü karakter, dizgideki UTF kod birimlerinin bir araya getirilmelerinden oluşturulan bir dchar'dır:

import std.array;

void main() {
    dchar harf = "şu".front; // front'un döndürdüğü dchar,
                             // ş'yi oluşturan iki char'ın
                             // bileşimidir
}

Dizginin eleman türü char olduğu halde yukarıdaki front'un dönüş türü dchar'dır. O dchar, dizgi içindeki iki UTF kod biriminden oluşmuştur ama kendisi dizginin elemanı değil, onlardan oluşan bir sağ değerdir.

Buna benzer olarak, bazı aralıkların ise hiç elemanları yoktur; böyle aralıklar yalnızca başka aralıkların elemanlarına erişim sağlamak için kullanılırlar. Bu, yukarıda Okul aralığında ilerlerken karşılaştığımız eleman kaybedilmesi sorununu da ortadan kaldırır. Bunun için örneğin Okul türünün kendisi değil, tek amacı okuldaki öğrencilere erişim sağlamak olan özel bir tür InputRange olarak tanımlanır.

Daha önce Okul içinde tanımlamış olduğumuz bütün aralık işlevlerini yeni ÖğrenciAralığı türüne taşıyalım. Dikkat ederseniz bu değişiklik sonrasında Okul artık kendisi bir aralık olarak kabul edilemez:

struct Okul {
    Öğrenci[] öğrenciler;
}

struct ÖğrenciAralığı {
    Öğrenci[] öğrenciler;

    this(Okul okul) {
        this.öğrenciler = okul.öğrenciler;
    }

    bool empty() const {
        return öğrenciler.length == 0;
    }

    ref Öğrenci front() {
        return öğrenciler[0];
    }

    void popFront() {
        öğrenciler = öğrenciler[1 .. $];
    }
}

Yeni aralık, kendisine verilen Okul'un öğrencilerini gösteren bir dilim oluşturur ve popFront() içinde o dilimi tüketir. Bunun sonucunda da asıl dizi değişmemiş olur:

    auto okul = Okul( [ Öğrenci("Ebru", 1),
                        Öğrenci("Derya", 2) ,
                        Öğrenci("Damla", 3) ] );

    yazdır(ÖğrenciAralığı(okul));

    assert(okul.öğrenciler.length == 3); // asıl dizi değişmez

Not: Bütün işlerini doğrudan üyesi olan dilime yaptırdığı için ÖğrenciAralığı'nın iyi bir örnek olmadığını düşünebiliriz. Çünkü nasıl olsa Okul.öğrenciler dizisinin bir dilimini kendimiz de doğrudan kullanabilirdik. Öte yandan, öğrenciler dizisi Okul'un özel bir üyesi de olabilirdi ve ÖğrenciAralığı en azından o özel üyeye erişim sağlamak için yararlı olabilirdi.

Sonsuz aralıklar

Kendi elemanları bulunmayan aralıkların başka bir yararı, sonsuz uzunlukta aralıklar oluşturabilmektir.

Bir aralığın hiç sonlanmaması, empty işlevinin her zaman için false değerinde olması ile sağlanır. Her zaman için false değerinde olan empty'nin işlev olması da gerekmeyeceğinden bir enum değer olarak tanımlanır:

    enum empty = false;                   // ← sonsuz aralık

Başka bir seçenek, değişmez bir static üye kullanmaktır:

    static immutable bool empty = false;  // üsttekiyle aynı

Bunun bir örneğini görmek için Fibonacci serisini üreten bir aralık düşünelim. Aşağıdaki aralık, yalnızca iki adet int üyesi bulunmasına rağmen sonsuz uzunluktaki Fibonacci serisi olarak kullanılabilir:

struct FibonacciSerisi
{
    int baştaki = 0;
    int sonraki = 1;

    enum empty = false;  // ← sonsuz aralık

    int front() const {
        return baştaki;
    }

    void popFront() {
        const ikiSonraki = baştaki + sonraki;
        baştaki = sonraki;
        sonraki = ikiSonraki;
    }
}

Not: Her ne kadar sonsuz olsa da, sayı türü olarak int kullandığı için int.max'tan daha büyük değerlere gelindiğinde FibonacciSerisi yanlış çalışır.

FibonacciSerisi nesneleri için empty'nin değeri hep false olduğundan, parametre olarak gönderildiğinde yazdır()'ın içindeki for döngüsü hiç sonlanmaz:

    yazdır(FibonacciSerisi());    // hiç sonlanmaz

Sonsuz aralıklar ancak sonuna kadar ilerlemenin gerekmediği durumlarda kullanılabilirler. FibonacciSerisi'nin yalnızca belirli adet elemanının nasıl kullanılabildiğini aşağıda göreceğiz.

Aralık döndüren işlevler

Bir ÖğrenciAralığı nesnesini yukarıda açıkça ÖğrenciAralığı(okul) yazarak oluşturmuş ve kullanmıştık.

Bazı durumlarda ise ÖğrenciAralığı gibi türleri açıkça yazmak yerine, o türün nesnelerini döndüren işlevlerden yararlanılır. Örneğin bütün işi bir ÖğrenciAralığı nesnesi döndürmek olan aşağıdaki işlev, kodlamayı kolaylaştırabilir:

ÖğrenciAralığı öğrencileri(ref Okul okul) {
    return ÖğrenciAralığı(okul);
}

// ...

    // Not: Burada da UFCS'ten yararlanılıyor
    yazdır(okul.öğrencileri);

Böylece kullanıcılar bazı durumlarda çok karmaşık olabilen özel aralık türlerinin isimlerini ve şablon parametrelerini bilmek ve açıkça yazmak yerine, onları döndüren işlevlerin kısa isimlerini hatırlayabilirler.

Bunun bir örneğini çok basit olan std.range.take işlevinde görebiliriz. "Al" anlamına gelen take(), kendisine verilen bir aralığın başındaki belirli adet elemana teker teker erişim sağlar. Aslında bu işlem take() işlevi tarafından değil, onun döndürmüş olduğu özel bir aralık türü tarafından gerçekleştirilir. Yine de biz take()'i kullanırken bunu bilmek zorunda değilizdir:

import std.range;

// ...

    auto okul = Okul( [ Öğrenci("Ebru", 1),
                        Öğrenci("Derya", 2) ,
                        Öğrenci("Damla", 3) ] );

    yazdır(okul.öğrencileri.take(2));

Yukarıdaki kullanımda take(), okul nesnesinin başındaki 2 elemana erişim sağlayacak olan geçici bir aralık nesnesi döndürür. yazdır() da take()'in döndürmüş olduğu bu geçici aralık nesnesini kullanır:

 Ebru(1) Derya(2)

Yukarıdaki işlemin sonucunda okul nesnesinde hiçbir değişiklik olmaz; onun hâlâ 3 elemanı vardır:

    yazdır(okul.öğrencileri.take(2));
    assert(okul.öğrenciler.length == 3);

take() gibi işlevlerin kendi amaçları için döndürdükleri aralıkların türleri çoğu durumda bizi ilgilendirmez. Onların isimleriyle bazen hata mesajlarında karşılaşabiliriz; veya daha önce de yararlanmış olduğumuz typeof ve stringof ile kendimiz de yazdırabiliriz:

    writeln(typeof(okul.öğrencileri.take(2)).stringof);

Çıktısı, take()'in döndürdüğü türün Take isminde bir şablon olduğunu gösteriyor:

Take!(ÖğrenciAralığı)
std.range ve std.algorithm modülleri

Kendi türlerimizi aralık olarak tanımlamanın çok büyük bir yararı; onları yalnızca kendi işlevlerimizle değil, Phobos ve başka kütüphanelerin aralık algoritmalarıyla da kullanabilmemizdir.

std.range modülünde özellikle aralıklarla ilgili olan çok sayıda olanak bulunur. std.algorithm modülü ise başka dillerin kütüphanelerinde de bulunan çok sayıda tanınmış algoritma içerir.

Bir örnek olarak std.algorithm.swapFront algoritmasını Okul türü ile kullanalım. "Öndekini değiş tokuş et" anlamına gelen swapFront, kendisine verilen iki InputRange aralığının ilk elemanlarını değiş tokuş eder.

import std.algorithm;

// ...

    auto türkOkulu = Okul( [ Öğrenci("Ebru", 1),
                             Öğrenci("Derya", 2) ,
                             Öğrenci("Damla", 3) ] );

    auto amerikanOkulu = Okul( [ Öğrenci("Mary", 10),
                                 Öğrenci("Jane", 20) ] );

    swapFront(türkOkulu.öğrencileri,
              amerikanOkulu.öğrencileri);

    yazdır(türkOkulu.öğrencileri);
    yazdır(amerikanOkulu.öğrencileri);

İki okuldaki ilk öğrenciler değişmiştir:

 Mary(10) Derya(2) Damla(3)
 Ebru(1) Jane(20)

Başka bir örnek olarak std.algorithm.filter algoritmasına bakalım. filter(), elemanların belirli bir kıstasa uymayanlarını elemekle görevli olan özel bir aralık döndürür. Bu işlem sırasında asıl aralıkta hiçbir değişiklik olmaz.

filter()'a verilen kıstas çok genel olarak uyanlar için true, uymayanlar için false üreten bir ifadedir. filter()'a şablon parametresi olarak verilen kıstası bildirmenin bir kaç yolu vardır. Bir yol, daha önce de karşılaştığımız gibi isimsiz bir işlev kullanmaktır. Kısa olması için ö olarak adlandırdığım parametre aralıktaki her öğrenciyi temsil eder:

    okul.öğrencileri.filter!(ö => ö.numara % 2)

Yukarıdaki ifadedeki kıstas, okul.öğrencileri aralığındaki elemanların numarası tek olanlarını seçer.

take() işlevinde olduğu gibi, filter() da özel bir aralık nesnesi döndürür. Böylece, döndürülen aralık nesnesini de doğrudan başka işlevlere gönderebiliriz. Örneğin, seçilmiş olan elemanları üretecek olan aralık nesnesi yazdır()'a gönderilebilir:

    yazdır(okul.öğrencileri.filter!(ö => ö.numara % 2));

O kodu sağdan sola doğru okuyarak şöyle açıklayabiliriz: okul.öğrencileri aralığındaki elemanların tek numaralı olanlarını seçen bir aralık oluştur ve yazdır() işlevine gönder.

Çıktısı yalnızca tek numaralı öğrencilerden oluşur:

 Ebru(1) Damla(3)

Seçilecek olan elemanlar için true üretmesi koşuluyla, kıstas filter()'a bir işlev olarak da bildirilebilir:

import std.array;

// ...

    bool başHarfiD_mi(Öğrenci öğrenci) {
        return öğrenci.isim.front == 'D';
    }

    yazdır(okul.öğrencileri.filter!başHarfiD_mi);

Yukarıdaki örnekteki kıstas işlevi, aldığı Öğrenci nesnesinin baş harfi D olanları için true, diğerleri için false döndürmektedir.

Not: O ifadede baş harf için öğrenci.isim[0] yazmadığıma dikkat edin. Öyle yazsaydım baş harfini değil, ilk UTF-8 kod birimini elde ederdim. Yukarıda da belirttiğim gibi; front, isim'i bir aralık olarak kullanır ve her zaman için ilk Unicode karakterini, yani ilk harfini döndürür.

O kodun sonucunda da baş harfi D olan öğrenciler seçilir ve yazdırılır:

 Derya(2) Damla(3)

std.range modülündeki generate, bir işlevin döndürdüğü değerlerin bir InputRange'in elemanlarıymış gibi kullanılmalarını sağlar. İşlev gibi çağrılabilen herhangi bir değişken (işlev göstergesi, isimsiz işlev, vs.) alır ve kavramsal olarak o işlevin döndürdüğü değerlerden oluşan bir InputRange nesnesi döndürür.

Döndürülen aralık nesnesi sonsuzdur. Bu nesnenin front niteliğine her erişildiğinde asıl işlev işletilir ve onun döndürdüğü değer, aralığın elemanı olarak sunulur. Bu nesnenin popFront işlevi ise hiç iş yapmaz.

Örneğin, aşağıdaki zarAtıcı nesnesi sonsuz bir aralık olarak kullanılabilmektedir:

import std.stdio;
import std.range;
import std.random;

void main() {
    auto zarAtıcı = generate!(() => uniform(0, 6));
    writeln(zarAtıcı.take(10));
}
[1, 0, 3, 5, 5, 1, 5, 1, 0, 4]
Tembellik

Aralık döndüren işlevlerin başka bir yararı, o aralıkların tembel olarak kullanılabilmeleridir. Bu hem program hızı ve bellek kullanımı açısından çok yararlıdır, hem de sonsuz aralıkların var olabilmeleri zaten bütünüyle tembellik olanağı sayesindedir.

Tembel aralıklar işlerini gerektikçe ve parça parça gerçekleştirirler. Bunun bir örneğini FibonacciSerisi aralığında görüyoruz: Elemanlar ancak gerektikçe popFront() işlevinde teker teker hesaplanırlar. FibonacciSerisi eğer tembel yerine hevesli bir aralık olsaydı, yani kullanılmadan önce bütün aralığı üretmeye çalışsaydı, sonsuza kadar işlemeye devam ederdi. Ürettiği elemanları saklaması da gerekeceği için sonsuz sayıdaki elemana da yer bulamazdı.

Hevesli aralıkların başka bir sakıncası, sonlu sayıda bile olsalar belki de hiç kullanılmayacak olan elemanlar için bile gereksizce yer harcayacak olmalarıdır.

Phobos'taki çoğu algoritma gibi take() ve filter() da tembellikten yararlanırlar. Örneğin FibonacciSerisi'ni take()'e vererek bu sonsuz aralığın belirli sayıdaki elemanını kullanabiliriz:

    yazdır(FibonacciSerisi().take(10));

Çıktısı yalnızca ilk 10 sayıyı içerir:

 0 1 1 2 3 5 8 13 21 34
ForwardRange, ilerleme aralığı

InputRange, elemanları çıkartıldıkça tükenen aralık kavramını ifade ediyordu.

Bazı aralıklar ise InputRange gibi işleyebilmelerinin yanında, aralığın belirli bir durumunu hatırlama yeteneğine de sahiptirler. FibonacciSerisi nesneleri bunu sağlayabilirler, çünkü FibonacciSerisi nesneleri serbestçe kopyalanabilirler ve bu kopyalar birbirlerinden bağımsız aralıklar olarak yaşamlarına devam edebilirler.

ForwardRange aralıkları, aralığın belirli bir andaki kopyasını döndüren save işlevini de sunan aralıklardır. save'in döndürdüğü kopyanın asıl aralıktan bağımsız olarak kullanılabilmesi şarttır. Örneğin bir kopya üzerinde ilerlemek diğer kopyayı ilerletmemelidir.

std.array modülünün eklenmiş olması dilimleri de otomatik olarak ForwardRange tanımına sokar.

save işlevini FibonacciSerisi için gerçekleştirmek istediğimizde nesnenin bir kopyasını döndürmek yeterlidir:

struct FibonacciSerisi {
// ...

    FibonacciSerisi save() const {
        return this;
    }
}

Döndürülen kopya, bu nesnenin kopyalandığı yerden devam edecek olan bağımsız bir aralıktır.

save'in döndürdüğü nesnenin asıl aralıktan bağımsız olduğunu aşağıdaki gibi bir program yardımıyla görebiliriz. Programda yararlandığım std.range.popFrontN(), kendisine verilen aralığın başından belirtilen sayıda eleman çıkartır. bilgiVer() işlevi de çıkışı kısa tutmak için yalnızca ilk beş elemanı gösteriyor:

import std.range;

// ...

void bilgiVer(T)(const dchar[] başlık, const ref T aralık) {
    writefln("%40s: %s", başlık, aralık.take(5));
}

void main() {
    auto aralık = FibonacciSerisi();
    bilgiVer("Başlangıçtaki aralık", aralık);

    aralık.popFrontN(2);
    bilgiVer("İki eleman çıkartıldıktan sonra", aralık);

    auto kopyası = aralık.save;
    bilgiVer("Kopyası", kopyası);

    aralık.popFrontN(3);
    bilgiVer("Üç eleman daha çıkartıldıktan sonra", aralık);
    bilgiVer("Kopyası", kopyası);
}

O kodun çıktısı, aralıktan'tan eleman çıkartılmış olmasının kopyası'nı etkilemediğini gösterir.:

                    Başlangıçtaki aralık: [0, 1, 1, 2, 3]
         İki eleman çıkartıldıktan sonra: [1, 2, 3, 5, 8]
                                 Kopyası: [1, 2, 3, 5, 8]
     Üç eleman daha çıkartıldıktan sonra: [5, 8, 13, 21, 34]
                                 Kopyası: [1, 2, 3, 5, 8]

bilgiVer() içinde aralıkları doğrudan writefln'e gönderdiğime ayrıca dikkat edin. Kendi yazdığımız yazdır() işlevinde olduğu gibi, stdio modülünün çıkış işlevleri de InputRange aralıklarını kullanabilirler. Bundan sonraki örneklerde yazdır() yerine stdio'nun çıkış işlevlerini kullanacağım.

ForwardRange aralıklarıyla işleyen bir algoritma örneği olarak std.range.cycle'a bakabiliriz. cycle(), kendisine verilen aralığı sürekli olarak tekrarlar. Başından tekrarlayabilmesi için aralığın ilk durumunu saklaması gerekeceğinden, bu aralığın bir ForwardRange olması şarttır.

Artık bir ForwardRange de kabul edilen FibonacciSerisi nesnelerini cycle() işlevine gönderebiliriz:

    writeln(FibonacciSerisi().take(5).cycle.take(20));

Hem cycle()'a verilen aralığın hem de cycle()'ın döndürdüğü aralığın sonlu olmaları için iki noktada take()'ten yararlanıldığına dikkat edin. Çıktısı, FibonacciSerisi aralığının ilk beş elemanının tekrarlanmasından oluşan aralığın ilk yirmi elemanıdır:

[0, 1, 1, 2, 3, 0, 1, 1, 2, 3, 0, 1, 1, 2, 3, 0, 1, 1, 2, 3]

Kodun anlaşılmasını kolaylaştırmak için ara değişkenler de tanımlanabilir. Yukarıdaki tek satırlık kodun bir eşdeğeri şudur:

    auto seri                      = FibonacciSerisi();
    auto başTarafı                 = seri.take(5);
    auto tekrarlanmışı             = başTarafı.cycle;
    auto tekrarlanmışınınBaşTarafı = tekrarlanmışı.take(20);

    writeln(tekrarlanmışınınBaşTarafı);

Tembelliğin yararını burada bir kere daha hatırlatmak istiyorum: İlk dört satırda yalnızca asıl işlemleri gerçekleştirecek olan geçici aralık nesneleri oluşturulur. Bütün ifadenin üretmiş olduğu sayılar, FibonacciSerisi.popFront() işlevi içinde ve ancak gerektikçe hesaplanırlar.

Not: ForwardRange olarak FibonacciSerisi türünü kullanacağımızı söylediğimiz halde cycle()'a FibonacciSerisi.take(5) ifadesini verdik. take()'in döndürdüğü aralığın türü parametresine uyar: parametre olarak ForwardRange verildiğinde döndürdüğü aralık da ForwardRange türündedir. Bunu sağlayan isForwardRange olanağını bir sonraki bölümde göstereceğim.

BidirectionalRange, çift uçlu aralık

BidirectionalRange aralıkları, ForwardRange işlevlerine ek olarak iki işlev daha sunarlar. back, front'un benzeri olarak aralığın sonundaki elemanı döndürür. popBack() de popFront()'un benzeri olarak aralığı sonundan daraltır.

std.array modülü eklendiğinde dilimler BidirectionalRange tanımına da girerler.

Örnek olarak BidirectionalRange aralığı gerektiren std.range.retro işlevini göstermek istiyorum. retro(), kendisine verilen aralığın front'unu back'ine, popFront()'unu da popBack()'ine bağlayarak aralıktaki elemanlara ters sırada erişilmesini sağlar:

    writeln([ 1, 2, 3 ].retro);

Çıktısı:

[3, 2, 1]

retro()'nun döndürdüğü özel aralığın bir benzerini çok basit olarak aşağıdaki gibi tanımlayabiliriz. Yalnızca int dizileriyle işlediği için çok kısıtlı olsa da aralıkların gücünü göstermeye yetiyor:

import std.array;
import std.stdio;

struct TersSırada {
    int[] aralık;

    this(int[] aralık) {
        this.aralık = aralık;
    }

    bool empty() const {
        return aralık.empty;
    }

    int front() const {
        return aralık.back;  // ← ters
    }

    int back() const {
        return aralık.front; // ← ters
    }

    void popFront() {
        aralık.popBack();    // ← ters
    }

    void popBack() {
        aralık.popFront();   // ← ters
    }
}

void main() {
    writeln(TersSırada([ 1, 2, 3]));
}

Aralığı ters sırada kullandığı için retro() ile aynı sonuç elde edilir:

[3, 2, 1]
RandomAccessRange, rastgele erişimli aralık

RandomAccessRange, belirli sıradaki elemanlarına [] işleci ile erişilebilen aralıkları ifade eder. İşleç Yükleme bölümünden hatırlayacağınız gibi, [] işleci opIndex() üye işlevi ile tanımlanır.

std.array modülü genel olarak dilimleri de RandomAccessRange tanımına sokar. Ancak; UTF-8 ve UFT-16 kodlamaları harflere sıra numarasıyla erişimi desteklemedikleri için, char ve wchar dizgileri harf erişimi açısından RandomAccessRange aralığı olarak kullanılamazlar. Öte yandan, UTF-32 kodlamasında kodlarla harfler bire bir karşılık geldiklerinden, dchar dizgileri harf erişiminde RandomAccessRange olarak kullanılabilirler.

Her türün opIndex() işlevini kendisine en uygun biçimde tanımlayacağı doğaldır. Ancak, bilgisayar biliminin algoritma karmaşıklıkları ile ilgili olarak bu konuda bir beklentisi vardır: Rastgele erişim, sabit zamanda gerçekleşmelidir. Sabit zamanda erişim, erişim için gereken işlemlerin aralıktaki eleman adedinden bağımsız olması anlamına gelir. Aralıkta ne kadar eleman olursa olsun, hiçbirisinin erişimi aralığın uzunluğuna bağlı olmamalıdır.

RandomAccessRange tanımına girebilmek için ek olarak aşağıdaki koşullardan birisinin daha sağlanmış olması gerekir:

veya

Sonsuz RandomAccessRange

Önce sonsuz ForwardRange tanımı üzerine kurulu olan bir RandomAccessRange örneğine bakalım. Bu tanıma girebilmek için gereken işlevler şunlardır:

FibonacciSerisi'nin en son tanımı onu bir ForwardRange yapmaya yetiyordu. Ancak, opIndex() işlevi FibonacciSerisi için sabit zamanda işleyecek şekilde gerçekleştirilemez; çünkü belirli bir elemana erişebilmek için o elemandan önceki elemanların da hesaplanmaları gerekir. Bunun anlamı; N'inci sıradaki elemanın hesaplanması için ondan önceki N-1 elemanın hesaplanması gerektiği, bu yüzden de işlem adedinin N'ye bağlı olduğudur.

opIndex() işlevinin sabit zamanda işletilebildiği bir örnek olarak tamsayıların karelerinden oluşan sonsuz bir aralık tanımlayalım. Böyle bir aralık sonsuz olduğu halde bütün elemanlarının değerlerine sabit zamanda erişilebilir:

class KareAralığı {
    int baştaki;

    this(int baştaki = 0) {
        this.baştaki = baştaki;
    }

    enum empty = false;

    int front() const {
        return opIndex(0);
    }

    void popFront() {
        ++baştaki;
    }

    KareAralığı save() const {
        return new KareAralığı(baştaki);
    }

    int opIndex(size_t sıraNumarası) const {
        /* Bu işlev sabit zamanda işler */
        immutable tamsayıDeğeri = baştaki + cast(int)sıraNumarası;
        return tamsayıDeğeri * tamsayıDeğeri;
    }
}

Not: KareAralığı'nın bir struct olarak tanımlanması daha uygun olurdu.

Hiçbir eleman için yer ayrılmadığı halde bu aralığın bütün elemanlarına [] işleci ile erişilebilir:

    auto kareler = new KareAralığı();

    writeln(kareler[5]);
    writeln(kareler[10]);

Çıktısı 5 ve 10 sıra numaralı elemanları içerir:

25
100

Sıfırıncı eleman her zaman için aralığın ilk elemanını temsil etmelidir. Bunu denemek için yine popFrontN()'den yararlanabiliriz:

    kareler.popFrontN(5);
    writeln(kareler[0]);

Aralığın ilk 5 elemanı sırasıyla 0, 1, 2, 3 ve 4'ün kareleri olan 0, 1, 4, 9 ve 16'dır. Onlar çıkartıldıktan sonraki ilk eleman artık bir sonraki sayının karesi olan 25'tir:

25

KareAralığı en işlevsel aralık olan RandomAccessRange olarak tanımlandığı için diğer aralık çeşitleri olarak da kullanılabilir. Örneğin InputRange olarak:

    bool sonİkiHaneAynı_mı(int sayı) {
        /* Doğru olabilmesi için en az iki rakamı bulunmalı */
        if (sayı < 10) {
            return false;
        }

        /* Son iki hanesi 11'e tam olarak bölünmeli */
        immutable sonİkiHane = sayı % 100;
        return (sonİkiHane % 11) == 0;
    }

    writeln(kareler.take(50).filter!sonİkiHaneAynı_mı);

Çıktısı, ilk 50 elemanın son iki hanesi aynı olanlarını içerir:

[100, 144, 400, 900, 1444, 1600]
Sonlu RandomAccessRange

Şimdi de sonlu uzunluklu BidirectionalRange tanımı üzerine kurulu olan bir RandomAccessRange örneğine bakalım. Bu çeşit bir aralık olarak kabul edilmek için gereken işlevler şunlardır:

Bu örnekte, kendisine verilen bütün aralıklardaki bütün elemanları sanki tek bir aralığın elemanlarıymış gibi sunan std.range.chain'in bir benzerini tasarlayalım. chain() her tür elemanla ve farklı aralıklarla işleyebilir. Bu örneği kısa tutabilmek için biz yalnızca int dizileriyle işleyecek şekilde tanımlayacağız.

Önce adına BirArada diyeceğimiz bu türün nasıl kullanılacağını göstermek istiyorum:

    auto aralık = BirArada([ 1, 2, 3 ],
                           [ 101, 102, 103]);
    writeln(aralık[4]);

İki farklı diziyle ilklenen aralık, [ 1, 2, 3, 101, 102, 103 ] elemanlarından oluşan tek bir diziymiş gibi kullanılacak. Örneğin dizilerin ikisinde de 4 numaralı eleman bulunmadığı halde diziler art arda düşünüldüklerinde 102, 4 numaralı eleman olarak kabul edilecek:

102

Bütün aralık nesnesi yazdırıldığında da elemanlar tek bir dizi gibi görünecekler:

    writeln(aralık);

Çıktısı:

[1, 2, 3, 101, 102, 103]

BirArada türünün bir yararı, bu işlemler gerçekleştirilirken elemanların yeni bir diziye kopyalanmayacak olmalarıdır. Bütün elemanlar kendi dizilerinde durmaya devam edecekler.

Belirsiz sayıda dilim ile ilklenecek olan bu aralık, Parametre Serbestliği bölümünde gördüğümüz belirsiz sayıda parametre olanağından yararlanabilir:

struct BirArada {
    const(int)[][] aralıklar;

    this(const(int)[][] aralıklar...) {
        this.aralıklar = aralıklar.dup;

        başıTemizle();
        sonuTemizle();
    }

// ...
}

Bu yapının elemanlarda değişiklik yapmayacağının bir göstergesi olarak eleman türünün const(int) olarak tanımlandığına dikkat edin. Öte yandan, ilerleme kavramını sağlayabilmek için dilimlerin kendileri popFront() tarafından değiştirilmek zorundadır.

Kurucu içinde çağrıldığını gördüğümüz başıTemizle() ve sonuTemizle() işlevleri, aralıkların baştaki ve sondaki boş olanlarını çıkartmak için kullanılıyorlar. Aralığa zaten bir katkıları bulunmayan boş aralıkların işlemleri karmaşıklaştırmaları böylece önlenmiş olacak:

struct BirArada {
// ...

    private void başıTemizle() {
        while (!aralıklar.empty && aralıklar.front.empty) {
            aralıklar.popFront();
        }
    }

    private void sonuTemizle() {
        while (!aralıklar.empty && aralıklar.back.empty) {
            aralıklar.popBack();
        }
    }
}

O işlevleri daha sonra popFront() ve popBack() içinden de çağıracağız.

başıTemizle() ve sonuTemizle() işlevlerinin başta ve sonda boş aralık bırakmayacaklarını bildiğimizden, tek bir alt aralığın bile kalmış olması bütün aralığın henüz tükenmediği anlamına gelir:

struct BirArada {
// ...

    bool empty() const {
        return aralıklar.empty;
    }
}

İlk alt aralığın ilk elemanı bu aralığın da ilk elemanıdır:

struct BirArada {
// ...

    int front() const {
        return aralıklar.front.front;
    }
}

İlk aralığın ilk elemanını çıkartmak, bu aralığın ilk elemanını çıkartmış olur. Bu işlem sonucunda ilk aralık boşalmış olabileceğinden, gerektiğinde o aralığın ve onu izleyen olası boş aralıkların da çıkartılmaları için başıTemizle() işlevinin çağrılması gerekir:

struct BirArada {
// ...

    void popFront() {
        aralıklar.front.popFront();
        başıTemizle();
    }
}

Aralığın belirli bir durumunun kopyası, elimizde bulunan alt aralıklarla ilklenen yeni bir BirArada nesnesi döndürerek sağlanabilir:

struct BirArada {
// ...

    BirArada save() const {
        return BirArada(aralıklar.dup);
    }
}

Aralığın son tarafındaki işlemler baş tarafındakilerin benzerleridir:

struct BirArada {
// ...

    int back() const {
        return aralıklar.back.back;
    }

    void popBack() {
        aralıklar.back.popBack();
        sonuTemizle();
    }
}

Bütün aralığın uzunluğu, alt aralıkların uzunluklarının toplamı olarak hesaplanabilir:

struct BirArada {
// ...

    size_t length() const {
        size_t uzunluk = 0;

        foreach (aralık; aralıklar) {
            uzunluk += aralık.length;
        }

        return uzunluk;
    }
}

Aynı işlem std.algorithm.fold işlevi ile daha kısa olarak da gerçekleştirilebilir. fold(), şablon parametresi olarak aldığı işlemi kendisine verilen aralıktaki bütün elemanlara uygular.

import std.algorithm;

// ...

    size_t length() const {
        return aralıklar.fold!((a, b) => a + b.length)(size_t.init);
    }

Şablon parametresindeki a şimdiye kadarki toplamı, b de aralıktaki her bir elemanı temsil eder. İlk işlev parametresi hesabın hangi aralıktaki elemanlara uygulanacağını, ikinci işlev parametresi de toplamın ilk değerini (burada 0) belirler. (aralıklar'ın UFCS'ten yararlanılarak fold'dan önce yazıldığına dikkat edin.)

Not: length her çağrıldığında uzunluğun böyle baştan hesaplanması yerine uzunluk isminde bir üyeden de yararlanılabilir. Bu üyenin değeri kurucu işlev içinde bir kere baştan hesaplanabilir, ve ondan sonra popFront() ve popBack() işlevleri her çağrıldıklarında teker teker azaltılabilir.

Belirli bir sıra numarasındaki elemanın döndürülebilmesi için bütün alt aralıklara baştan sona doğru bakılması ve sıra numarasının hangi aralıktaki bir elemana denk geldiğinin bulunması gerekir:

struct BirArada {
// ...

    int opIndex(size_t sıraNumarası) const {
        /* Hata mesajı için saklıyoruz */
        immutable baştakiSıraNumarası = sıraNumarası;

        foreach (aralık; aralıklar) {
            if (aralık.length > sıraNumarası) {
                return aralık[sıraNumarası];

            } else {
                sıraNumarası -= aralık.length;
            }
        }

        throw new Exception(
            format("Geçersiz sıra numarası: %s (uzunluk: %s)",
                   baştakiSıraNumarası, this.length));
    }
}

Not: opIndex, yukarıdaki uyarının aksine sabit zamanda gerçekleşemez. Bu aralığın kabul edilir derecede hızlı işleyebilmesi için aralıklar üyesinin fazla uzun olmaması gerekir.

Tanımladığımız bu aralık, istediğimiz sayıda int dizisiyle kullanılmaya hazırdır. Kendisine vereceğimiz dizileri take() ve array() işlevleri yardımıyla bu bölümde tanımladığımız türlerden bile edinebiliriz:

    auto aralık = BirArada(FibonacciSerisi().take(10).array,
                           [ 777, 888 ],
                           (new KareAralığı()).take(5).array);

    writeln(aralık.save);

Çıktısı, üç aralığın tek aralıkmış gibi kullanılabildiğini gösterir:

[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 777, 888, 0, 1, 4, 9, 16]

Bu aralığı başka çeşit aralık kullanan algoritmalara da gönderebiliriz. Örneğin BidirectionalRange gerektiren retro()'ya:

    writeln(aralık.save.retro);
[16, 9, 4, 1, 0, 888, 777, 34, 21, 13, 8, 5, 3, 2, 1, 1, 0]

BirArada'yı bu bölümde öğrendiklerimizin bir uygulaması olarak tasarladık. Programlarınızda daha kapsamlı olan std.range.chain'i kullanmanızı öneririm.

OutputRange, çıkış aralığı

Şimdiye kadar gördüğümüz aralıklar hep elemanlara erişimle ilgili olan aralıklardır. OutputRange ise çıkış aralığıdır. stdout'ta olduğu gibi elemanların belirli bir hedefe yazıldıkları akımları temsil ederler.

OutputRange aralıklarının gerektirdiği işlemi yukarıda kısaca put(aralık, eleman) olarak belirtmiştim. put(), std.range modülünde tanımlanmış olan bir işlevdir; çıkış aralığının hangi olanaklara sahip olduğunu static if yardımıyla derleme zamanında belirler ve elemanı aralığa gönderirken elemana ve aralığa en uygun olan yöntemi kullanır.

put()'un sırayla denediği durumlar ve seçtiği yöntemler aşağıdaki tablodaki gibidir. Tablodaki durumlara yukarıdan aşağıya doğru bakılır ve uygun olan ilk durum seçilir. Tabloda A, aralığın türünü; aralık, bir aralık nesnesini; E, eleman türünü; ve e de bir eleman nesnesini temsil ediyor:

Olası Durum Seçilen Yöntem
A türünün parametre olarak E alan
put isminde bir üye işlevi varsa
aralık.put(e);
A türünün parametre olarak E[] alan
put isminde bir üye işlevi varsa
aralık.put([ e ]);
A bir InputRange aralığıysa
ve e, aralık.front'a atanabiliyorsa
aralık.front = e;
aralık.popFront();
E bir InputRange aralığıysa
ve A aralığına kopyalanabiliyorsa
for (; !e.empty; e.popFront())
put(aralık, e.front);
A, parametre olarak E alabiliyorsa
(A örneğin bir delegate olabilir)
aralık(e);
A, parametre olarak E[] alabiliyorsa
(A örneğin bir delegate olabilir)
aralık([ e ]);

Ben bu kullanımlardan birincisinin bir örneğini göstereceğim: Tanımlayacağımız aralık türünün put isminde bir işlevi olacak ve bu işlev çıkış aralığının eleman türünü parametre olarak alacak.

Tanımlayacağımız çıkış aralığı, kurulurken belirsiz sayıda dosya ismi alsın. Daha sonradan put() işlevi ile yazdırılan elemanları hem bu dosyaların hepsine, hem de stdout'a yazdırsın. Ek olarak, her elemandan sonra yine kurucusunda aldığı ayracı yazdırsın.

struct ÇokHedefeYazan {
    string ayraç;
    File[] dosyalar;

    this(string ayraç, string[] dosyaİsimleri...) {
        this.ayraç = ayraç;

        /* stdout her zaman dahil */
        this.dosyalar ~= stdout;

        /* Belirtilen her dosya ismi için yeni bir dosya  */
        foreach (dosyaİsmi; dosyaİsimleri) {
            this.dosyalar ~= File(dosyaİsmi, "w");
        }
    }

    /* Dilimlerle kullanılan put() (dizgiler hariç) */
    void put(T)(T dilim)
            if (isArray!T && !isSomeString!T) {
        foreach (eleman; dilim) {
            // Bu, aşağıdaki put()'u çağırmaktadır
            put(eleman);
        }
    }

    /* Dilim olmayan türlerle ve dizgilerle kullanılan put() */
    void put(T)(T değer)
            if (!isArray!T || isSomeString!T) {
        foreach (dosya; dosyalar) {
            dosya.write(değer, ayraç);
        }
    }
}

Her türden çıkış aralığı yerine geçebilmesi için put() işlevini de şablon olarak tanımladım. Bu sayede aşağıda hem int hem de string aralığı olarak kullanabiliyoruz.

Phobos'ta OutputRange kullanan bir algoritma std.algorithm.copy'dir. copy(), bir InputRange aralığının elemanlarını bir OutputRange aralığına kopyalayan çok basit bir işlevdir.

import std.traits;
import std.stdio;
import std.algorithm;

// ...

void main() {
    auto çıkış = ÇokHedefeYazan("\n", "deneme_0", "deneme_1");
    copy([ 1, 2, 3], çıkış);
    copy([ "kırmızı", "mavi", "yeşil" ], çıkış);
}

Yukarıdaki kod, giriş aralıklarındaki elemanları hem stdout'a, hem de "deneme_0" ve "deneme_1" isimli dosyalara yazar:

1
2
3
kırmızı
mavi
yeşil
Dilimlerin OutputRange olarak kullanılmaları

std.range, dilimleri OutputRange tanımına da sokar. (std.array ise yalnızca giriş aralıkları tanımına sokar). Ancak, dilimlerin OutputRange olarak kullanılmalarının beklenmedik bir etkisi vardır: OutputRange olarak kullanılan dilim, her put() işlemine karşılık bir eleman kaybeder. Üstelik kaybedilen eleman, yeni atanmış olan baştaki elemandır.

Bunun nedeni, put() üye işlevleri bulunmayan dilimlerin yukarıdaki tablodaki şu yönteme uymalarıdır:

    aralık.front = e;
    aralık.popFront();

Her bir put() için yukarıdaki kod işletildiğinde hem baştaki elemana yeni değer atanır, hem de popFront()'un etkisiyle baştaki eleman dilimden çıkartılır:

import std.stdio;
import std.range;

void main() {
    int[] dilim = [ 1, 2, 3 ];
    put(dilim, 100);
    writeln(dilim);
}

Bir OutputRange olarak kullanıldığı halde dilim eleman kaybetmiştir:

[2, 3]

Bu yüzden dilimin kendisi değil, başka bir dilim OutputRange olarak kullanılmalıdır:

import std.stdio;
import std.range;

void main() {
    int[] dilim = [ 1, 2, 3 ];
    int[] dilim2 = dilim;

    put(dilim2, 100);

    writeln(dilim2);
    writeln(dilim);
}

Bu sefer ikinci dilim tükendiği halde asıl dilim istediğimiz elemanlara sahiptir:

[2, 3]
[100, 2, 3]    ← istenen sonuç

Burada önemli bir noktaya dikkat etmek gerekir: OutputRange olarak kullanılan dilimin uzunluğu otomatik olarak artmaz. Dilimde yeterli yer olması programcının sorumluluğundadır:

    int[] dilim = [ 1, 2, 3 ];
    int[] dilim2 = dilim;

    foreach (i; 0 .. 4) {    // ← dilimde 4 elemana yer yok
        put(dilim2, i * 100);
    }

popFront() nedeniyle boşalan dilimde yer kalmadığı için program boş dilimin ilk elemanı bulunmadığını bildiren bir hatayla sonlanır:

core.exception.AssertError@...: Attempting to fetch the front
of an empty array of int

std.array.Appender ve onun kolaylık işlevi appender dilimleri sonuna eklenen bir OutputRange olarak kullanmaya yarar. appender'ın döndürdüğü özel aralık nesnesinin kendi put() işlevi, verilen elemanı dilimin sonuna ekler:

import std.array;

// ...

    auto sonunaEkleyen = appender([ 1, 2, 3 ]);

    foreach (i; 0 .. 4) {
        sonunaEkleyen.put(i * 100);
    }

Yukarıdaki koddaki appender bir dizi ile çağrılıyor, ve onun döndürmüş olduğu nesne put() işlevi çağrılarak bir OutputRange olarak kullanılıyor. appender'ın bir çıkış olarak kullanıldığında edindiği elemanlara .data niteliği ile erişilir:

    writeln(sonunaEkleyen.data);

Çıktısı:

[1, 2, 3, 0, 100, 200, 300]

Appender dizilerin ~= işlecini de destekler:

    sonunaEkleyen ~= 1000;
    writeln(sonunaEkleyen.data);

Çıktısı:

[1, 2, 3, 0, 100, 200, 300, 1000]
OutputRange parametreli toString

toString üye işlevleri temsilci parametre alabildikleri gibi, OutputRange de alabilirler. format, writefln, ve writeln gibi işlevler de karakterleri bu çıkış aralığının ara belleğine yerleştirerek daha etkin işlerler.

Bütün OutputRange aralıklarıyla kullanılabilmesi için, böyle tanımlanmış bir toString işlevinin şablon olarak tanımlanması gerekir. Aşağıdaki örnek bir şablon kısıtlamasından da yararlanıyor:

import std.stdio;
import std.range;

struct S {
    void toString(O)(ref O o) const
            if (isOutputRange!(O, char)) {
        put(o, "merhaba");
    }
}

void main() {
    auto s = S();
    writeln(s);
}

Dikkat ederseniz, main içindeki kod açıkça OutputRange tanımlamamaktadır. OutputRange nesnesini writeln oluşturur ve yazdıracağı karakterleri önce bu nesnenin ara belleğine yerleştirir:

merhaba
Aralık şablonları

Bu bölümde kendi yazdığımız çoğu örnekte int aralıkları kullandık. Oysa aralıkların ve aralık kullanan algoritmaların şablon olarak tasarlanmaları kullanışlılıklarını büyük ölçüde arttırır.

std.range modülü aralıklarla ilgili olan çok sayıda yardımcı şablon da tanımlar. Bunların nasıl kullanıldıklarını bir sonraki bölümde göstereceğim.

Özet