Modern C++ (6) : Lambda İfadeleri

Evet arkadaşlar, C++ yazılarımıza kaldığımız yerden devam ediyoruz.

Lambda yazım ile temel C++ 11 özelliklerini tamamlamış olacağız. Sonrasında belki STL için de bir yazı yazıp, daha sonra C++ 14/17/20’e yelken açabiliriz. Aslında oldukça geniş konu, fakat ben bu yazımda sizlere temel noktaları aktarmaya çalışacağım. Öncelikle  benim gibi bilgisayar bilimleri ile uğraşanlarımız Lambda denilince hemen aklınıza Lambda calculus gelecektir ve bunlar arasında bir ilişki var mı diye de düşünebilirsiniz. Kısa cevap: bir Lisp kadar ilintili değil ama uzun cevap için aşağıdaki sonuçları için google amcamıza danışabiliriz. Bu arada Lisp “Lambda Calculus” ‘ın gerçek anlamda bir gerçeklemesi ve dilin bütün yapısı zaten jenerik programlamayı kolaylaştırmaya yönelik tasarlanmıştır.

Ayrıca “Lambda Calculus” ile ilgili de referanslar bölümüne bir kaç kaynak ekliyorum [6]. Bu konuda daha derinlemesine bilgi almak isteyen arkadaşlarım oraya yönelebilirler.

Lambda İfadeleri

Gelelim asıl mevzumuza Lambda İfadeleri. Hepimiz yazılım geliştirirken çeşitli işler için kısa kısa metotlar ve fonksiyonlar yazma ihtiyacı duymuşuzdur, hele de STL ile haşır neşirseniz, özellikle STL algoritma kütüphanesinde bu tarz kullanımlara çok sık ihtiyaç duyarsınız. Bunlar bir iki tane olunca sıkıntı olmasa da, sayıları arttıkça hem kod bu tarz satırlarla dolmakta, okunabilirlik azalmakta ve de bu kodların idamesi zorlaşmaktadır. İşte C++ 11 ile gelen Lambda ifadeleri ile artık:
– Kullanılacak olan fonksiyonlar ihtiyaç duyulan yerlerde tanımlayabilir,
– Yukarıdan aşağıya olan mantıksal ve doğal akışı koruyabilir (fonksiyonlar genelde farklı yerlerde tanımlanırlar, bu sebeple git geller okunabilirliği düşürebiliyor. Özellikle tek seferlik),
– Mevcut kapsam içerisindeki değişkenlere de erişim sunulabilmektedir.

Lambda ifadelerini tanımlayacak olursak. Lambda ifadeleri kısaca anonim fonksiyon olarak ifade edilebilir. Hatta bazı kaynaklar (cppreference) “Mevcut kapsamdaki değişkenleri
yakalayabilen isimsiz fonksiyon nesneleri olarak da tanımlar”. Bir diğer deyişle, lambda ifadeleri aslında çağrılabilecek olan bir kod birimini ifade etmektedirler. C++’dan önce aslında Haskell, C#, Java gibi bir çok dilde buna benzer, ihtiyaç duyulan yerde fonksiyon tanımlamasına olanak sağlayan yapılar sunulmaktaydı (tam ya da daha geniş bir liste için Wikipedia ya alalım sizi).

Meraklılar için lambdaların altında yatan mekanizmayı (derleyici lambda ifadelerini nasıl anlamlandırıyor veya arkada üretilen kod nedir, performansa bir etkisi var mı vs.) yazımın sonuna eklediğim ayrı bir bölümde anlatacağım.

Lambda ifadelerinin genel şablonu aşağıdaki gibi özetlenebilir. Burada * ile işaretlenen kısımlar opsiyonel olan parçalardır.

Detaylara inmeden önce aşağıda basit bazı lambda tanımlamaları ve bunların nasıl çağrıldığına ilişkin bir kaç örnek sizlere aktarayım.

Yukarıda auto değişkenleri içerisinde lambda ifadelerine ilişkin işaretçiler tutulmakta. Bir de STL’e ilişkin örneğe bakalım hemen. Önce STL de mevcut durumda 5 ten büyük sayıları nasıl saydıracağımıza bakalım:

Şimdi de aynı işi lambdalar ile yapalım. Siz hangisi tercih edersiniz 🙂

Şimdi biraz daha derinlere inebiliriz. Önce lambdalar ile değişken yakalama mekanizmasına bakacağız daha sonra da parametre geçirme ve dönüş mekanizmalarını inceleyeceğiz. Bu arada lambda ifadelerinin arka planı için ise son bölüme başvurabilirsiniz.

Lambda ifadeleri ile değişken yakalama (“Variable Capture Mechanism”):

Normal fonksiyonlardan farklı olarak lambda ifadeleri, mevcut tanımlandığı blokta/kapsamda (“scope”) tanımlanmış olan değişkenlere de erişim sağlayabilmektedir. Buna “Variable Capture Mechanism” diğer bir ifade ile Değişkenleri Yakalama Mekanizması diyebiliriz.

Bu mekanizma ile aşağıdaki şekillerde değişkenleri yakalayabiliriz:
Değer/Kopya yakalama (“Capture by value”)
Referans yakalama (“Capture by reference”)
Hem değer hem de referans yakalama (“Capture by both value and reference (mixed capture) “)

Detaylarını örnekler ile aşağıda anlatacağım ama özetleme adına bu kullanımlara ilişkin yazım şekillerinin özetini aşağıda bulabilirsiniz:

  1. [&] : tanımlandığı kapsam içerisindeki bütün değişkenlerin referansları yakalanır/kullanılabilir (capture all external variables by reference)
  2. [=] : tanımlandığı kapsam içerisindeki bütün değişkenlerin kopyaları yakalanır/kullanılabilr (capture all external variables by value)
  3. [a, &b] : a değişkeninin kopyasını, b değişkeninin de referansını yakalar/kullanır
  4. [] : herhangi bir ortam değişkeni yakalanmaz ve sadece lambda ifadeleri içerisindeki yerel değişkenlere erişiminiz olur

Şimdi bu zamana kadar yazdıklarımıza ilişkin yakama örnekleri üzerinden kullanımlarına bakalım.

Yukarıdaki 1. örnekte herhangi bir değişken yakalanmadığı duruma örnek (kullanım 4). 2. örnek ise geçerli bir tanımlama değil, çünkü x değişkeni lambda ifadesine herhangi bir şekilde geçirilmemiş. 3. örnek bir önceki örneğin geçerli hali ve sadece kopyasının geçirildiği duruma örnek teşkil etmektedir. 4. örnek de benzer şekilde z dışındaki bütün değişkenler lambda ifadesi içerisine kopyalanarak geçirilmekte. z kullanılmadığı için geçirilmemektedir. Son örnek te geçerli bir satır değil. Neden sizce? Biraz düşünün sebebi yazının sonunda (ipucu diğer satırlardan farkı nedir “()” ne yapar).

Şimdi biraz da değişkenlerin referans olarak yakalanmasına ilişkin bir kaç örnek inceleyelim.

Bu kullanımların yanında lambda ifadelerini sınıf metotları içerisinde de kullanabilirsiniz. Fakat burada bir hususa dikkat etmeniz gerekiyor. Lambdaların her bir için derleyici özel ve ayrı bir sınıf oluşturmakta ve bu sebeple çalışma zamanında hepsinin kendi kapsamları oluyor. Bir diğer ifade ile sınıf metotları içerisindeki lambda ifadeleri içerisinde bulundukları sınıf değişkenlerine ulaşamazlar.  Bunun için ilgili sınıfın işaretçisi “this” ile geçirilmesi gerekmektedir.  Hemen bir örnek ile inceleyelim:

Parametre Geçirme ve Dönüş Değerleri:

Kapsam yakalama ve değişkenlerin lambda ifadelerine geçirilmelerinden sonra lambda ifadelerine parametrelerin geçirilmesi hususuna eğilelim. Daha önce de bahsettiğim gibi lambda ifadelerine normal fonksiyonlar gibi parametreler geçirebiliyoruz. Lambda ifadeleri için de normal fonksiyonlara uygulanan parametre geçirme kuralları uygulanmakta. C++ 14 e kadar normal fonksiyonlardan farklı olarak varsayılan parametre değerlerini lambdalarda kullanamıyoruz, C++ 14 de bu kısıtlama da ortadan kalkmakta. Lambda ifadelerindeki parantezlerde aslında isteğe bağlı eğer herhangi bir parametre geçirilmiyor ise “[] {}” tamamen geçerli bir kullanım (hatta en basit kullanım diyebiliriz).

Lambda ifadelerinde normal fonksiyonlarda olduğu gibi değerler dönülebilmektedir. Fonksiyonlardan farklı olarak, lambdalarda dönüş değerleri direk ifade edilebilmeleri yanında derleyici tarafından otomatik olarak da belirlenebiliyor. Eğer lambda tanımlaması içerisinde birden fazla dönüş ifadesi var ise ya da dönüş tipi çıkarımında bulunamayacağı durumlarda dönüş tipi de lambda ifadesinde belirtilmelidir.  Ayrıca yazılan kodta bir dönüş işlemi “return deyimi” yok ise lambda işlevin geri dönüş değerinin türü void kabul edilir.

Not olarak lambda ifadelerinde throw kullanımı da mümkün (normal C++ standardında throw kullanımı “depreceated” olarak işaretlense de dilden tamamen henüz çıkarılmadı).

Şimdi bu son hususlara ilişkin örnek kodları inceleyelim:

STL Kullanım:

Yukarıda aslında STL’e kullanıma ilişkin bir kaç örnek vermeye çalıştım. Aşağıda çok bilindik sıralama fonksiyonu için olan kullanıma bakalım:

Bu kullanım yanında lambda ifadeleri şablonlara (“template”) parametre olarak ta geçirilebilirler. Aşağıda buna ilişkin bir örnek kullanım görebilirsiniz:

Bunların yanında std::function kullanarak lambda lari metotlara da geçirebilirsiniz.

C++ Lambda larının Çalışma Mekanizması

Lambda’lar yazımın başında da bahsettiğim üzere aslında çağrılabilir küçük kod parçaları olarak adlandırabiliyor. Diğer fonksiyonlardan farklı olarak içerisinde bulunduğu kapsamda tanımlı olan değişkenlere erişim de sağlayabilmekte. Peki mevcut mekanizmalardan farkı nedir? Yani normal fonksiyon, functor sınıflarından ( operator() tanımlayan sınıflar) ne farkı var? Bu başlıkta buna bakacağız.

Basit olarak aslında siz bir lambda tanımlaması yaptığınız zaman, derleyici arka planda sizler için bir “functor” sınıfı oluşturuyor. Bu sınıfların her biri eşsiz ve her tanımlama için ayrı ayrı oluşturuluyor. Örneğin:

için derleyici:

tanımı oluşturuyor. Bu bir anlamda “syntatic sugar” dediğimiz yani işimiz kolaylaştıran bir mekanizma olarak düşünebiliriz. Şimdi biraz daha derine inelim ve assembly kodlarını karşılaştıralım :).

Önce hiç bir değişkenin yakalanmadığı duruma bakalım. Bu arada bu kodlar (x86-64 gcc 8.2 derleyicisinden çıkan kodlar).

Normal fonksiyon tanımı için aşağıdaki gibi bir kod üretilmekte:

“Functor” için ise aşağıdaki gibi kod üretilmekte:

Lambda için ise aşağıdaki gibi:

herhangi bir değişken yakalanmadığı durumda görüleceği üzere “functor” ile lambda ifadelerine ilişkin üretilen kod aynı. Standart metot ile ise ufak bir fark var.

Şimdi ortam değişkenlerini kopyalayarak yakalama durumunu inceleyelim. Bu durumda normal metotları kullanamayacağız. Sadece “functor” lar ile lambdaları karşılaştıracağız.

Burada fonksiyon nesneleri için bizi ilgilendiren iki metot var. Bunlar yapıcı ve () operatörü. Şimdi bunlar için
üretilen kodlara bakalım:

Yapıcıya ilişkin kod kısaca aslında esi yazmacı içeriğini rdi yazmacı ile ifade edilen belleğe kopyalamaya karşılık geliyor.
Buna geçirilen değerleri görmek için de main() içerisinde buna ilişkin üretilen koda bakalım.

Buradan x’in rbp-0x4 yazmacında saklandığını ve daha sonra dolaylı yoldan esi yazmacına yazıldığını görüyoruz. rbp-0x20 adresini rdi atıldığını görüyoruz. lea komutu ve sonrasındaki satırlar ile bizim fonksiyon nesnemizi saklayan adres.

Asıl () operatörü için üretilen kod ile yapıcı için üretilen kod birbirine oldukça benzemekte. En büyük fark ise m_x in değerini okumak için çağrılan iki satır komut.

Şimdi lambda ifadesi için üretilen koda bakalım:

Bu kod parçasının da fonksiyon nesne objesi için üretilen ile aynı olduğu görülebilir. Burada tabi lambda ifadesinin oluşturulmasına ilişkin kodunun nerede olduğu sorulabilir.
Onun için main içerisine bakıyoruz:

Görüleceği üzere fonksiyon nesne objesinin yapıcısı için üretilen kod, lambda için üretilen kodtan oldukça fazla. Bunun da sebebi yapıcı için olan oluşturucu kodu normal üretilen kodun içerisine gömülmekte. Ortam değişkenlerinin referans olarak geçirildiği durumda aslında yukarıdakine benzer tek fark değer yerine işaretçilerin geçirilmesi.

Bu kullanımlara baktığımızda özellikle fonksiyon nesneleri ile lambda ifadeleri birbirlerinin hemen hemen aynısı. Ana farklılıklar:
– “functor” lar ve lambda ifadeleri fazladan bir this göstergeci geçiriyorlar (fazladan 8 byte),
– Lambda ifadelerine ilişkin yapıcı kodları lambda ifadelerinin içerisine yediriliyor ve bu sayede kopyalamaya ilişkin fazladan oluşturulan kod miktarı azaltılmış oluyor.

Sonuç olarak lambda ifadelerinin performans anlamında bir yük getirmediğini ifade edebiliriz ([8] de daha detaylı bir performans karşılaştırması görebilirsiniz).

Yine kısa dedik sözü uzattık 🙂 olsun lambda ifadeleri oldukça önemli ve nispeten yeni bir kabiliyet, siz yazılımperverlere faydası olduysa ne ala 🙂

Kaynaklar:

  1. https://www.cprogramming.com/c++11/c++11-lambda-closures.html
  2. https://www.geeksforgeeks.org/lambda-expression-in-c/
  3. https://web.mst.edu/~nmjxv3/articles/lambdas.html
  4. https://blog.feabhas.com/2014/03/demystifying-c-lambdas/
  5. https://www.wikiwand.com/en/Lambda_calculus
  6. https://www.inf.fu-berlin.de/lehre/WS03/alpi/lambda.pdf
  7. https://en.cppreference.com/w/cpp/language/lambda
  8. https://vittorioromeo.info/index/blog/passing_functions_to_functions.html

Bir cevap yazın

E-posta hesabınız yayımlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir

This site uses Akismet to reduce spam. Learn how your comment data is processed.