PY3.7 Dataclass Yapılarına Giriş

qRunt11

Katılımcı Üye
14 Mar 2017
695
2

PY3.7 Dataclass Yapılarına Giriş

"Readability counts." - The Zen of Python


GİRİŞ

Python 3.7 ile birlikte gelen ilginç özelliklerin yanında bulunmakta dataclass'lar.



NOT: Yukarıdaki alıntıda bulunan variable annotations kısmı değişkenleri tanımlamamız için gerekli olduğundan iyi anlaşılması önemlidir. Bunun için Python'ın kendi dokümanlarından PEP526'yı okuyabilirsiniz.

Olarak tanımlamış PEP557. Yani kısaca dataclass'lar normal class'lardaki boilerplate kodları yazmanızı otomatikleştiren, genel olarak içerisinde veri barındıran -dataclass'lar içerisinde her zaman sadece veri barındırmak zorunda değillerdir, rahatlıkla normal class olarak da kullanılabilirler- class'lardır.

Bir dataclass, @​dataclass decorator'ı ile aşağıdaki gibi tanımlanır:

Kod:
from dataclasses import dataclass

@​dataclass
class foo:
    bar: str
    baz: int

Dataclass'lar hali hazırda basit bazı özellikler ile birlikte gelir. Örnek vermek gerekirse bir dataclass'ın durumlarını dataclass'ı tanımladıktan hemen sonra yazdırabilir, karşılaştırabilir ve yeniden tanımlayabilirsiniz:
Kod:
>>> f = foo('n', 19)
>>> f.bar
'n'
>>> f
foo(bar='n', baz=19)
>>> f == foo('n', 19)
True
>>> f.baz = 193114

Bu havalı class'ı sıkıcı class yapısı ile kıyaslarsak eğer:
Kod:
class s_foo:
    def __init__(self, bar, baz):
        self.bar = bar
        self.baz = baz

Çok daha fazla kod yazmamıza gerek olmamasına rağmen, bunun gibi basit bir class'ı tanımlamak için bile bar ve baz değişkenlerini kodda 3 defa yazmak durumunda kaldık. Unutmayın, boilerplate'in her türlüsü sıkıcı ve zararlıdır. Ayrıca, bu acınası derecede sıkıcı olan class'ı kullanmaya kalkarsanız objelerin tanımlarının tam olarak.. tanımlayıcı olmadığını göreceksiniz. Aynı zamanda bir nedenden ötürü foo, foo'ya eşit değil:
Kod:
>>> sf = s_foo('p', 4)
>>> sf.bar
'p'
>>> sf
<__main__.s_foo object at 0x7fb63fe4yd1n>
>>> sf == s_foo('p', 4)
False

NOT: Yukarıda iki adet aynı class'ın birbirlerine eşit olmamasının nedeni, Python'ın sandığımız gibi karşılaştırma yapmıyor oluşu. Python class'ların durumlarını karşılaştırmak yerine hafızadaki yerlerini karşılaştırır. Her ne kadar class'lar aynı olsa da Python bir objenin yaşamı boyunca hafızadaki yerinin eşsiz olduğunu garantilediği için aynı değillerdir. Bu nedenle de iki adet sıkıcı class, birbirlerine eşit değildir.

Görünüşe göre fazla ciddiye almadığımız dataclass'ımız bizim için arkaplanda ciddi anlamda efor sarf ediyor: Varsayılan olarak dataclass'lar, geliştirici-dostu güzel tanımlar için __repr__'ı ve basit karşılaştırmalar yapmak için __eq__ dunder fonksiyonlarını uygular. Sıkıcı class'ımızın havalı dataclass'ımız gibi gözükmesini sağlamak için aşağıdaki satırlara ihtiyacı var:
Kod:
class s_foo:
    def __init__(self, bar, baz):
        self.bar = bar
        self.baz = baz
    
    def __repr__(self):
        return (f'{self.__class__.__name__}'
                   f'(bar={self.bar!r}, baz={self.baz!r})')

    def __eq__(self, other):
        if other.__class__ is not self.__class__:
            return NotImplemented
        return (self.bar, self.baz) == (other.bar, other.baz)


ALTERNATİFLER

Basit veri yapıları için büyük ihtimalle zaten tuple veya dict ile karşılaştınız. Haydi yukarıdaki dataclass'ımızı bu ikisine çevirelim:
Kod:
>>> sfoo_tuple = ('h4ckn', 0)
>>> sfoo_dict   = {'bar': 'h4ckn', 'baz': 0}

Çalışıyor, evet, ama lütfen şu koda bir bakın.. ne kadar da sıkıcı. Aynı zamanda sfoo_'nun neyi temsil ettiğini ve _tuple versiyonu için değerlerin sırasını hatırlamalısınız, _dict için ise keyword'lerin aynı olması gerekiyor: {'value': 'b0mb', 'baz': 0} beklenildiği gibi çalışmayacaktır.

Daha da ilerlersek, eğer benim gibi üşengeç, tembel ve noktalama işaretleri dahil olmak üzere Türkçe'nin ırzına geçen birsi iseniz, bu yapılar size göre değil.
Kod:
>>> sfoo_tuple[0] # Spesifik olarak belirlediğimiz bir isimle veriye erişemiyoruz
'h4ckn'
>>> sfoo_dict['baz'] # .baz olsaydı daha havalı olurdu
0

Biraz daha havalı bir örnek düşünürsek aklıma namedtuple geliyor. Küçük ve okunabilir veri yapıları için uzun zamandır kullanılmakta ve sevilmekte. Haydi bizim sıkıcı class'ımızı bir de bununla deneyelim:
Kod:
from collections import namedtuple

sfoo_nt = namedtuple('sfoo_nt', ['bar', 'baz'])

sfoo_nt'nin bu tanımı bizim dataclass'ımız ile tamamen aynı çıktıyı verecek:
Kod:
>>> hello = sfoo_nt('hi', 0)
>>> hello.bar
'hi'
>>> hello
sfoo_nt(bar='hi', baz=0)
>>> hello == sfoo_nt('hi', 0)
True

"O zaman neden dataclass'lar ile uğraşıyoruz ki?" Öncelikle, şu ana kadar gördüğünüz dataclass'ların özellikleri, dataclass'ların özelliklerinin çok az bir kısmı.. apayrı bir dünya var dataclass'lar hakkında. Ayrıca, namedtuple da normal bir tuple'dır ve bu karşılaştırmalarda bazı sorunlara yol açabilir:
Kod:
>>> hello = ('hi', 0)
True
>>> hate = namedtuple('hate', ['myself', 'you'])
>>> hello = hate('hi', 0)
True

Bu arada hazır sözü açılmışken, dataclass'ları da namedtuple gibi tanımlayabiliyoruz:
Kod:
from dataclasses import make_dataclass

Item = make_dataclass('Item', ['name', 'id'])

Elbette, dataclass'lar her zaman namedtuple'lardan üstün değiller, ayrıntıya inmeyeceğim mantığı anladınız.

Son bir alternatif olarak, dataclass'ların ilham aldığı attrs projesi. Bu da böyle tanımlanıyor:
Kod:
import attr

  [USER=669840]attr[/USER][/USER].s
class foo:
    bar = attr.ib()
    baz = attr.ib()

Ve dataclass ile hemen hemen aynı şekilde kullanılıyor.

Bu kadarını yeterli görüyorum, gerisini burada anlatmak lüzumsuz olacaktır. Zaten PEP557'de de belirtildiği gibi, daha birçok alternatif bulunmakta..


VARSAYILAN HER ŞEYDİR

Dataclass'ların argümanlarına/değişkenlerine varsayılan değer vermek son derecede kolaydır:
Kod:
from dataclasses import dataclass

@​dataclass
class Item:
    name: str
    id: int = 0 # default 0!

Şimdiye kadar hep bir değişken tipi (str, int, ...) belirledik, belirlemek istemezsek de:
Kod:
from dataclasses import dataclass
from typing import Any

@​dataclass
class Item:
    name: str
    id: Any = 0

Zaten değişken tipini yazmak sadece okunabilirliliği arttırmak için olan bir şey, runtime'da Python bu değişkenleri hiçbir şekilde zorlamıyor. Yani:
Kod:
>>> i = Item(777722, 'idc')
Item(name=777722, id='idc')

Üstteki kod parçacığı hiçbir hata vermiyor, çünkü değişken tiplerinin canı cehenneme. Eğer bunun gibi hataları yakalamak istiyorsanız mypy gibi bir type checker kullanabilirsiniz.


FONKSİYONLAR, FONKSİYONLAR, FONKSİYONLAR!

Dataclass'lara normal class'a ekler gibi fonksiyon ekleyebiliriz:
Kod:
from dataclasses import dataclass

@​dataclass
class Item:
    name: str
    id: int = 0

    def rule(self, key):
        return ((len(self.name) * self.id) + key

Tam da tahmin ettiğimiz gibi çalışıyor:
Kod:
>>> lamp = Item('Lamp', 0)
>>> lamp.rule(3)
3


VARSAYILAN FONKSİYONLAR

Birçok eşya yaratmayı deneyelim bakalım, kırtasiye malzemeleri güzel bir örnek olabilir:
Kod:
MATERIALS = ['Pencil', 'Pen', 'Book',
                      'Notebook', 'A4 Paper',
                      'Marker', 'Highlighter']

def create_default_materials():
    return [Item(item, i) for i, item in enumerate(MATERIALS)]

Çalışıyor mu diye test edersek eğer:
Kod:
[Item(name='Pencil', id=0), Item(name='Pen', id=1), 
Item(name='Book', id=2), Item(name='Notebook', id=3), 
Item(name='A4 Paper', id=4), Item(name='Marker', id=5), 
Item(name='Highlighter', id=6)]

Artık bir envantere ihtiyacımız var. Inventory.items değişkeni için varsayılan değere de bu fonksiyonu yazabiliriz:
Kod:
from dataclasses import dataclass
from typing import List

@​dataclass
class Inventory:
    items: List[Item] = create_default_materials()

NOT: Böyle bir tanım Python'ın en büyük anti-pattern'lerinden birisidir: varsayılan olarak değişken değer kullanmak. Buradaki problem şu ki Inventory'nin tüm versiyonları aynı .items'ın varsayılan liste objesini kullanacak. Kısacası bir Inventory'den herhangi bir Item silindiği vakit Inventory'nin tüm versiyonlarından da silinecek. Aslına bakarsanız dataclass'lar bunun olmasının önüne geçip size ValueError döndürüyor.

Böyle bir tanım yapmak yerine dataclass'ların default_factory adında bir zımbırtıları var, tam da bunun gibi durumlar için geliştirilmiş. default_factory'i -ve daha birçok havalı şeyi- kullanmak için field() fonksiyonunu kullanmamız gerek:
Kod:
from dataclasses import dataclass, field
from typing import List

@​dataclass
class Inventory:
    items: List[Item] = field(default_factory=create_default_materials)

default_factory herhangi bir argümansız fonksiyonu argüman olarak alabilir. Artık bir envanter dolusu kırtasiye malzemesi yaratmak inanılmaz derecede kolay oldu:
Kod:
>>> Inventory()
Inventory(items=[Item(name='Pencil', id=0), Item(name='Pen', id=1), 
                          Item(name='Book', id=2), Item(name='Notebook', id=3), 
                          Item(name='A4 Paper', id=4), Item(name='Marker', id=5),
                          Item(name='Highlighter', id=6)])

field() fonksiyonunun tek kullanım alanı default_factory değil elbette. Diğer kullanım alanlarına bakmak için dokümanları okumak yeterli.


ARKASINDA DURDUĞUN ŞEYİ İYİ TEMSİL ET

Artık bir envanter dolusu kırtasiye malzemesi yaratabilmekteyiz:
Kod:
>>> Inventory()
Inventory(items=[Item(name='Pencil', id=0), Item(name='Pen', id=1), 
                          Item(name='Book', id=2), Item(name='Notebook', id=3), 
                          Item(name='A4 Paper', id=4), Item(name='Marker', id=5),
                          Item(name='Highlighter', id=6)])

Böyle bir çıktı bize gerekli bilgileri verse de çok verbose olduğu için göz ağrıtıyor. Bunun gibi bir çıktının 100, 500, 1000 eşyalık varyasyonlarını düşündüğünüzde bunun ne kadar kötü bir durum olduğunu anlayacaksınız.

Genelde Python'da bir objenin iki farklı temsil şekli vardır, bunlar:
- repr(obj): obj.__repr__() ile tanımlanmakta olup objenin geliştirici-dostu bir tanımını çıktı olarak verir. Dataclass'lar bu tanımı otomatik olarak tanımlamakta. Ayrıca mümkünse bu objeyi tekrar yaratabilen bir kod parçacığı olmalıdır - ancak şimdilik biz bu kısma girmeyeceğiz.
- str(obj): obj.__str__() ile tanımlanmakta olup objenin kullanıcı-dostu bir tanımını çıktı olarak verir. Dataclass'lar bu tanımı otomatik olarka tanımlamazlar, bu nedenle Python tanımı bulamadığından __repr__ çıktısı verir.

Şimdi gözlerimizi bozan çıktıyı kullanıcı dostu ve sevimli bir şey yapalım:
Kod:
@​dataclass
class Item:
    name: str
    id: int = 0

    def __str__(self):
        return f'{self.id}:{self.name}'

@​dataclass
class Inventory:
    items: List[Item] = field(default_factory=create_default_materials)

    def __str__(self):
        items = [f'{i!s}' for i in self.items]
        return f'{self.__class__.__name__}({items})'
Kod:
>>> Inventory()
Inventory(['0:Pencil', '1:Pen', '2:Book', 
                '3:Notebook', '4:A4 Paper', 
                '5:Marker', '6:Highlighter'])


BİR BAŞKA DEĞİŞMEZ

Bir dataclass'ı immutable yani değişmez (alanlarındaki değerleri değişmeyen) yapmak için @​dataclass decorator'ının frozen argümanından yararlanacağız:
Kod:
from dataclasses import dataclass

@​dataclass(frozen=True)
class foo:
    bar: str
    baz: int = 0

Artık foo dataclass'ının herhangi bir alanını değiştirdiğimizde hata vermesi gerek.
Kod:
>>> f = foo('n', 19)
>>> f.baz = 193114
dataclasses.FrozenInstanceError: cannot assign to field 'baz'

Unutulmamalıdır ki eğer dataclass'ınız mutable/değişken alanlar barındırıyorsa bu alanlar hala değişebilir. Yani:
Kod:
@​dataclass(frozen=True)
class Item:
    name: str
    id: int = 0

@​dataclass(frozen=True)
class Inventory:
    items: List[Item]

Item ve Inventory immutable olsa da Inventory.items değiştirilebilir haldedir:
Kod:
>>> inv = Inventory(Item('A3 Paper', 14), Item('Sticky Notes', 26))
>>> print(inv)
Inventory(['14:A3 Paper', '26:Sticky Notes'])
>>> inv.items[0] = Item('Ruler', 17)
>>> print(inv)
Inventory(['17:Ruler', '26:Sticky Notes'])

Bundan kaçınmak için bu örnekteki Inventory sınıfı list yerine tuple kullanmalıydı.


GENETİK GÜZELLİK

Dataclass'lar Python için normal class'lardır demiştik. Yani, sonunda inheritance ile uğraşabileceğiz:
Kod:
from dataclasses import dataclass

@​dataclass
class Employee:
  name: str
  age: int
  salary: int

@​dataclass
class Developer(Employee):
  lang: str
Kod:
>>> Employee('Jason', 35, 55000)
Employee(name='Jason', age=35, salary=55000)
>>> Developer('Amy', 22, 60000, 'Python')
Developer(name='Amy', age=22, salary=60000, lang='Python')


O(2**n)PTİMİZASYON

Normal class'da da sık sık kullanılan bir yöntem olan __slots__ dunder değişkeninden bahsetmek istiyorum biraz. Bunu ilk gördüğümde tek diyebildiğim şey "sihirli" olmuştu - çünkü ciddi anlamda daha az bellek harcayarak daha hızlı işlem yapmanızı sağlıyor.



Dataclass'larda da normal class'larda kullanıldığı gibi kullanabiliriz slots'u:
Kod:
@​dataclass
class foo:
    bar: str
    baz: int

@​dataclass
class slot_foo:
    __slots__ = ['bar', 'baz']
    
    bar: str
    baz: int

Genel olarak __slots__'a class'daki değişkenlerin listesi atanır. Dokümanların da söylediği üzere, her değişken için ayrı bir __dict__'in yaratılmasını önler ve bu da bizim daha az bellek harcayarak daha hızlı işlem yapmamızı sağlar. Ne kadar iyi? Bu kadar:
Kod:
>>> from pympler.asizeof import asizeof
>>> from timeit import timeit
>>> f, sf = foo('test', 2222), slot_foo('test', 2222)
>>> asizeof(f)
368
>>> asizeof(sf)
216
>>> timeit('f.bar', setup='from source import foo; f=foo("test", 2222)')
0.06058196300000418
>>> timeit('sf.bar', setup='from source import slot_foo; sf=slot_foo("test", 2222)')
0.05940312399999925

Hızları konusunda çok bir fark olmasa da değişken sayısı ve tuttukları verilerin boyutları arttıkça __slots__'un üstünlüğü daha iyi anlaşılacaktır. Hafıza kullanımında ise bu kadar basit bir örnekte bile üstünlüğü görülmekte.

Ancak dikkat edilmelidir ki __slots__'un çalışma prensibinden ötürü değişkenlere varsayılan veriler atayamayız. Yani:
Kod:
from dataclasses import dataclass

@​dataclass
class foo:
    __slots__ = ['bar']
    bar: int = 10
Kod:
ValueError: 'bar' in __slots__ conflicts with class variable

Eğer neden bunun gibi yararlı bir şeyi dataclass'lar otomatik olarak yapmıyor diye soracak olursanız, bu soruyu soran ilk kişi olmazsınız. Ancak maalesef Python 3.7 için dataclass'lara direkt olarak __slots__ desteği kabul edilmedi. Nedeni ise:


Yine de sürekli yazmak zorunda kalmak istemiyorsanız, bu küçük fonksiyonu kullanabilirsiniz.


PERDE ARKASI

Dataclass'ların perde arkasında yaptıkları aslında oldukça basit şeyler, bunun nedeni tanımları gereği code generator olmaları. Farklı bir örnek üzerinden ilerleyelim:
Kod:
from dataclasses import dataclass

@​dataclass
class Color:
    hue: int
    saturation: float
    lightness: float = .5

Basit bir HSL (Hue-Saturation-Lightness) renk class'ı. Bu küçük dataclass'ın perde arkasında ürettiği kod ise:
Kod:
from dataclasses import Field, _MISSING_TYPE, _DataclassParams

class Color:
    'Color(hue: int, saturation: float, lightness: float = .5)'

    def __init__(self, hue: int, saturation: float, lightness: float = .5) -> None:
        self.hue          = hue
        self.saturation = saturation
        self.lightness   = lightness

    def __repr__(self):
        return (self.__class__.__qualname__ +
                   f'(hue={self.hue!r}, saturation={self.saturation!r}, '
                   f'lightness={self.lightness!r})')
    
    def __eq__(self, other):
        if other.__class__ is self.__class__:
            return (self.hue, self.saturation, self.lightness) == (other.hue, other.saturation, other.lightness)
        return NotImplemented
    
    __hash__ = None

    hue: int
    saturation: float
    lightness: float = .5

    __dataclass_params__ = _DataclassParams(
        init              = True,
        repr             = True,
        eq               = True,
        order           = False,
        unsafe_hash = False,
        frozen          = False
    )

    __dataclass_fields__ = {
        'hue': Field(default=_MISSING_TYPE,
                        default_factory=_MISSING_TYPE,
                        init=True,
                        repr=True,
                        hash=None,
                        compare=True,
                        métadata={}),
        'saturation': Field(default=_MISSING_TYPE,
                                 default_factory=_MISSING_TYPE,
                                 init=True,
                                 repr=True,
                                 hash=None,
                                 compare=True,
                                 métadata={}),
        'lightness': Field(default=.5,
                                default_factory=_MISSING_TYPE,
                                init=True,
                                repr=True,
                                hash=None,
                                compare=True,
                                métadata={})
    }

__dataclass_fields__['hue'].name          = 'hue'
__dataclass_fields__['hue'].type            = int
__dataclass_fields__['saturation'].name = 'saturation'
__dataclass_fields__['saturation'].type   = float
__dataclass_fields__['lightness'].name   = 'lightness'
__dataclass_fields__['lightness'].type     = float

Biraz fazla gözüküyor, değil mi? Özellikle son kısımları nadiren görebilirsiniz gerçek hayatta. Öyleyse bu bedavaya aldığımız kodu satır satır, madde madde inceleyelim:

- Güzel bir docstring: 'Color(hue: int, saturation: float, lightness: float = .5)'. Aslına bakarsak bu bizim class'ı type annotations ile tanımlarken kullandığımız satırın aynısı.

- __init__'in tanımında type annotations kullanılmış, böylelikle kodun okunabilirliliğini arttırılmış.

- __repr__ aynen normal bir kişinin elle yazacağı gibi yazılmış, __qualname__ hariç. __qualname__ en üst seviye'de, yani iç içe olmayan, class'lar ve fonksiyonlar için __name__ ile tamamen aynıdır. İç içe yazımlarda ise kendi objesinden başlayıp en üst seviyedeki objeye kadar aşina olduğumuz şekilde bir yol/path tutar (Ör. 'A.B.c': A en üst seviyedeki class, B A'nın altındaki class ve c ise B'nin bir fonksiyonu).

- __eq__ ise çoğu kişinin yaptığı şekilde bir karşılaştırma yapıyor, tek bir farkla: kendi class'ı ile diğer class'ın aynı olup olmadığını kontrol ediyor, aynı değiller ise NotImplemented hatası döndürüyor. Bunun sayesinde daha önceden gördüğümüz gibi, normal class'ların yaptığı beklenmedik karşılaştırmayı yapmıyor. Elbette ne beklediğinize göre değişir bu.

- __hash__ çoğu zaman istediğimiz gibi kapatılmış. Bunun nedeni ise:


- Dataclass'ın nasıl oluşturulduğunu güzel ve açıklayıcı bir şekilde gösteriyor (__dataclass_params__ değişkeni)

- Her alan/field hakkında detaylı ve métadata dahil olmak üzere gösteriyor (__dataclass_fields__ değişkeni)


Sonuç olarak, gayet basit bir şekilde tanımladığımız dataclass'lar bize hem bu güzellikleri (ve daha fazlasını) hem de daha fazla okunabilirlilik veriyor.


SON

Son olarak kesinleştirmek adına söylemem gerekirse: bu yazı dataclass'lar hakkında her şeyi anlatmıyor. Python dokümanlarında bir ton keşfedilmeyi bekleyen bilgi var. Bu yazıyı sadece dataclass'ların varlıklarını bilmenizi ve aklınızda bir yer etmesini sağlamak amacıyla yazdım.

Lütfen sadece bu yazıyı okuyup "ben her şeyi biliyorum" havalarına girmeyin, bilmediğiniz pek çok şey var - ve bu şeylerin sayısını azaltmak adına aşağıda hem bu yazıyı yazarken kullandığım, hem de aşırı detaya giren kaynaklar bulunmakta.

- Raymond Hettinger - Dataclasses: The code generator to end all code generators - PyCon 2018
- Raymond Hettinger - SF Python Holiday 2017
- PEP557 -- Data Classes
- Python dataclass dokümanları
- Eric V. Smith'in PEP557 implementasyonu
- David Beazley - Python 3 Métaprogramming

tg:AAAAAEegUNvn9YFHV9YbvQ

~ nig + {p4, debr10, b0mb, hacknology, gbmdpof}



Konuya renk eklesen süper olur insan okudukça sıkılıyor.
Şimdi okursanız sıkılmazsınız. İyi okumalar.
 
Üst

Turkhackteam.org internet sitesi 5651 sayılı kanun’un 2. maddesinin 1. fıkrasının m) bendi ile aynı kanunun 5. maddesi kapsamında "Yer Sağlayıcı" konumundadır. İçerikler ön onay olmaksızın tamamen kullanıcılar tarafından oluşturulmaktadır. Turkhackteam.org; Yer sağlayıcı olarak, kullanıcılar tarafından oluşturulan içeriği ya da hukuka aykırı paylaşımı kontrol etmekle ya da araştırmakla yükümlü değildir. Türkhackteam saldırı timleri Türk sitelerine hiçbir zararlı faaliyette bulunmaz. Türkhackteam üyelerinin yaptığı bireysel hack faaliyetlerinden Türkhackteam sorumlu değildir. Sitelerinize Türkhackteam ismi kullanılarak hack faaliyetinde bulunulursa, site-sunucu erişim loglarından bu faaliyeti gerçekleştiren ip adresini tespit edip diğer kanıtlarla birlikte savcılığa suç duyurusunda bulununuz.