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:
InputRange
, giriş aralığı:empty
,front
vepopFront()
işlevleriForwardRange
, ilerleme aralığı: ek olaraksave
işleviBidirectionalRange
, çift uçlu aralık: ek olarakback
vepopBack()
işlevleriRandomAccessRange
, rastgele erişimli aralık: ek olarak[]
işleci (sonlu veya sonsuz olmasına göre başka koşullar da gerektirir)
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:
OutputRange
, çıkış aralığı:put(aralık, eleman)
işlemini desteklemek
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:
-
empty
: "boş mu" anlamına gelir ve aralığın sonuna gelinip gelinmediğini bildirir; aralık boş kabul edildiğindetrue
, değilsefalse
döndürmelidir -
front
: "öndeki" anlamına gelir ve aralığın başındaki elemana erişim sağlar -
popFront()
: "öndekini çıkart" anlamına gelir ve aralığın başındaki elemanı çıkartarak aralığı baş tarafından daraltır
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:
- yalnızca bu bölümü ilgilendiren üyeleri yazacağım
- bütün türleri yapı olarak tasarlayacağım
private
,public
,const
gibi aslında yararlı olan belirteçler kullanmayacağım- sözleşmeli programlama veya birim testi olanaklarından yararlanmayacağım
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:
- sonsuz bir
ForwardRange
olmak
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:
InputRange
'in gerektirdiğiempty
,front
vepopFront()
ForwardRange
'in gerektirdiğisave
RandomAccessRange
'in gerektirdiğiopIndex()
- sonsuz olabilmek için
empty
'nin değerinin derleme zamanındafalse
olarak belirlenmiş olması
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:
InputRange
'in gerektirdiğiempty
,front
vepopFront()
ForwardRange
'in gerektirdiğisave
BidirectionalRange
'in gerektirdiğiback
vepopBack()
RandomAccessRange
'in gerektirdiğiopIndex()
- aralığın uzunluğunu bildiren
length
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 alanput isminde bir üye işlevi varsa |
aralık.put(e);
|
A türünün parametre olarak E[] alanput isminde bir üye işlevi varsa |
aralık.put([ e ]);
|
A bir InputRange aralığıysave e , aralık.front 'a atanabiliyorsa |
aralık.front = e;
aralık.popFront();
|
E bir InputRange aralığıysave 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
- Aralıklar veri yapılarıyla algoritmaları birbirlerinden soyutlayan ve birbirleriyle uyumlu olarak kullanılmalarını sağlayan olanaktır.
- Aralıklar D'ye özgü bir kavramdır ve Phobos'ta çok kullanılır.
- Phobos'taki çoğu algoritma kendisi işlem yapmak yerine özel bir aralık nesnesi döndürür ve tembellikten yararlanır.
- UFCS aralık algoritmaları ile çok uyumludur.
- Dizgiler
InputRange
olarak kullanıldıklarında elemanlarına harf harf erişilir. InputRange
'in gerektirdiği işlevlerempty
,front
vepopFront()
'tur.ForwardRange
'in gerektirdiği ek işlevsave
'dir.BidirectionalRange
'in gerektirdiği ek işlevlerback
vepopBack()
'tir.- Sonsuz
RandomAccessRange
'inForwardRange
'e ek olarak gerektirdiği işlevopIndex()
'tir. - Sonlu
RandomAccessRange
'inBidirectionalRange
'e ek olarak gerektirdiği işlevleropIndex()
velength
'tir. std.array.appender
dilimlerin sonuna ekleyen birOutputRange
nesnesi döndürür.- Dilimler sonlu
RandomAccessRange
aralıklarıdır - Sabit uzunluklu diziler aralık değillerdir.