Atribut Kelas Python: Panduan yang Terlalu Menyeluruh

Diterbitkan: 2022-03-11

Saya memiliki wawancara pemrograman baru-baru ini, layar ponsel di mana kami menggunakan editor teks kolaboratif.

Saya diminta untuk mengimplementasikan API tertentu, dan memilih untuk melakukannya dengan Python. Mengabstraksikan pernyataan masalah, katakanlah saya membutuhkan kelas yang instance-nya menyimpan beberapa data dan beberapa other_data .

Aku menarik napas dalam-dalam dan mulai mengetik. Setelah beberapa baris, saya memiliki sesuatu seperti ini:

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

Pewawancara saya menghentikan saya:

  • Pewawancara: “Baris itu: data = [] . Saya tidak berpikir itu Python yang valid?”
  • Saya: “Saya cukup yakin itu. Itu hanya menetapkan nilai default untuk atribut instance.”
  • Pewawancara: “Kapan kode itu dieksekusi?”
  • Saya: “Saya tidak begitu yakin. Saya hanya akan memperbaikinya untuk menghindari kebingungan. ”

Untuk referensi, dan untuk memberi Anda gambaran tentang apa yang saya tuju, inilah cara saya mengubah kode:

 class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...

Ternyata, kami berdua salah. Jawaban sebenarnya terletak pada pemahaman perbedaan antara atribut kelas Python dan atribut instance Python.

Atribut kelas Python vs. Atribut instance Python

Catatan: jika Anda memiliki pegangan ahli pada atribut kelas, Anda dapat melompat ke depan untuk menggunakan kasus.

Atribut Kelas Python

Pewawancara saya salah karena kode di atas valid secara sintaksis.

Saya juga salah karena tidak menetapkan "nilai default" untuk atribut instance. Sebaliknya, ini mendefinisikan data sebagai atribut kelas dengan nilai [] .

Dalam pengalaman saya, atribut kelas Python adalah topik yang diketahui banyak orang, tetapi hanya sedikit yang mengerti sepenuhnya.

Variabel Kelas Python vs. Variabel Instance: Apa Perbedaannya?

Atribut kelas Python adalah atribut kelas (melingkar, saya tahu), daripada atribut turunan dari kelas.

Mari kita gunakan contoh kelas Python untuk mengilustrasikan perbedaannya. Di sini, class_var adalah atribut kelas, dan i_var adalah atribut instan:

 class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_var = i_var

Perhatikan bahwa semua instance kelas memiliki akses ke class_var , dan itu juga dapat diakses sebagai properti dari kelas itu sendiri :

 foo = MyClass(2) bar = MyClass(3) foo.class_var, foo.i_var ## 1, 2 bar.class_var, bar.i_var ## 1, 3 MyClass.class_var ## <— This is key ## 1

Untuk pemrogram Java atau C++, atribut kelas serupa—tetapi tidak identik—dengan anggota statis. Kita akan melihat bagaimana mereka berbeda nanti.

Ruang Nama Kelas vs. Instance

Untuk memahami apa yang terjadi di sini, mari kita bicara secara singkat tentang ruang nama Python .

Namespace adalah pemetaan dari nama ke objek, dengan properti bahwa tidak ada hubungan nol antara nama di namespace yang berbeda. Mereka biasanya diimplementasikan sebagai kamus Python, meskipun ini diabstraksikan.

Tergantung pada konteksnya, Anda mungkin perlu mengakses namespace menggunakan sintaks titik (misalnya, object.name_from_objects_namespace ) atau sebagai variabel lokal (misalnya, object_from_namespace ). Sebagai contoh konkrit:

 class MyClass(object): ## No need for dot syntax class_var = 1 def __init__(self, i_var): self.i_var = i_var ## Need dot syntax as we've left scope of class namespace MyClass.class_var ## 1

Kelas Python dan instance dari kelas masing-masing memiliki ruang nama berbeda yang diwakili oleh atribut yang telah ditentukan MyClass.__dict__ dan instance_of_MyClass.__dict__ , masing-masing.

Saat Anda mencoba mengakses atribut dari instance kelas, pertama-tama ia melihat namespace instance -nya. Jika menemukan atribut, ia mengembalikan nilai terkait. Jika tidak, ia akan mencari di namespace kelas dan mengembalikan atribut (jika ada, jika tidak, akan membuat kesalahan). Sebagai contoh:

 foo = MyClass(2) ## Finds i_var in foo's instance namespace foo.i_var ## 2 ## Doesn't find class_var in instance namespace… ## So look's in class namespace (MyClass.__dict__) foo.class_var ## 1

Namespace instance mengambil supremasi atas namespace kelas: jika ada atribut dengan nama yang sama di keduanya, namespace instance akan diperiksa terlebih dahulu dan nilainya dikembalikan. Berikut adalah versi sederhana dari kode (sumber) untuk pencarian atribut:

 def instlookup(inst, name): ## simplified algorithm... if inst.__dict__.has_key(name): return inst.__dict__[name] else: return inst.__class__.__dict__[name]

Dan, dalam bentuk visual:

pencarian atribut dalam bentuk visual

Bagaimana Atribut Kelas Menangani Tugas

Dengan mengingat hal ini, kita dapat memahami bagaimana atribut kelas Python menangani tugas:

  • Jika atribut kelas diatur dengan mengakses kelas, itu akan menimpa nilai untuk semua instance. Sebagai contoh:

     foo = MyClass(2) foo.class_var ## 1 MyClass.class_var = 2 foo.class_var ## 2

    Pada tingkat namespace… kami menyetel MyClass.__dict__['class_var'] = 2 . (Catatan: ini bukan kode yang tepat (yang akan menjadi setattr(MyClass, 'class_var', 2) ) karena __dict__ mengembalikan dictproxy, pembungkus yang tidak dapat diubah yang mencegah penugasan langsung, tetapi ini membantu demi demonstrasi). Kemudian, ketika kita mengakses foo.class_var , class_var memiliki nilai baru di namespace kelas dan dengan demikian 2 dikembalikan.

  • Jika variabel kelas Paython diatur dengan mengakses sebuah instance, itu akan menimpa nilai hanya untuk instance itu . Ini pada dasarnya menimpa variabel kelas dan mengubahnya menjadi variabel instan yang tersedia, secara intuitif, hanya untuk instance itu . Sebagai contoh:

     foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1

    Pada tingkat namespace… kami menambahkan atribut class_var ke foo.__dict__ , jadi ketika kami mencari foo.class_var , kami mengembalikan 2. Sementara itu, instance MyClass lainnya tidak akan memiliki class_var di namespace instance mereka, jadi mereka terus menemukan class_var di MyClass.__dict__ dan dengan demikian mengembalikan 1.

Mutabilitas

Pertanyaan kuis: Bagaimana jika atribut class Anda memiliki tipe yang bisa berubah ? Anda dapat memanipulasi (memutilasi?) atribut kelas dengan mengaksesnya melalui instance tertentu dan, pada gilirannya, akhirnya memanipulasi objek referensi yang diakses oleh semua instance (seperti yang ditunjukkan oleh Timothy Wiseman).

Ini paling baik ditunjukkan dengan contoh. Mari kembali ke Service yang saya definisikan sebelumnya dan lihat bagaimana penggunaan variabel kelas saya dapat menyebabkan masalah di kemudian hari.

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

Tujuan saya adalah memiliki daftar kosong ( [] ) sebagai nilai default untuk data , dan untuk setiap instance Service memiliki datanya sendiri yang akan diubah dari waktu ke waktu berdasarkan instance-by-instance. Tetapi dalam kasus ini, kami mendapatkan perilaku berikut (ingat bahwa Service mengambil beberapa argumen other_data , yang arbitrer dalam contoh ini):

 s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data.append(1) s1.data ## [1] s2.data ## [1] s2.data.append(2) s1.data ## [1, 2] s2.data ## [1, 2]

Ini tidak baik—mengubah variabel kelas melalui satu instance akan mengubahnya untuk semua yang lain!

Pada tingkat namespace… semua instance Service mengakses dan memodifikasi daftar yang sama di Service.__dict__ tanpa membuat atribut data mereka sendiri di namespace instance mereka.

Kita bisa menyiasatinya dengan menggunakan tugas; yaitu, alih-alih mengeksploitasi daftar yang berubah-ubah, kami dapat menetapkan objek Service kami untuk memiliki daftar mereka sendiri, sebagai berikut:

 s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2]

Dalam hal ini, kami menambahkan s1.__dict__['data'] = [1] , sehingga Service.__dict__['data'] asli tetap tidak berubah.

Sayangnya, ini mengharuskan pengguna Service memiliki pengetahuan mendalam tentang variabelnya, dan tentu saja rentan terhadap kesalahan. Dalam arti tertentu, kami akan mengatasi gejalanya daripada penyebabnya. Kami lebih suka sesuatu yang benar dengan konstruksi.

Solusi pribadi saya: jika Anda hanya menggunakan variabel kelas untuk menetapkan nilai default ke calon variabel instan Python, jangan gunakan nilai yang dapat diubah . Dalam hal ini, setiap instance Service akan menimpa Service.data dengan atribut instance-nya sendiri pada akhirnya, jadi menggunakan daftar kosong sebagai default menyebabkan bug kecil yang mudah diabaikan. Alih-alih yang di atas, kita bisa saja:

  1. Terjebak pada atribut instan sepenuhnya, seperti yang ditunjukkan dalam pendahuluan.
  2. Hindari menggunakan daftar kosong (nilai yang bisa berubah) sebagai "default" kami:

     class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...

    Tentu saja, kita harus menangani kasus None dengan tepat, tapi itu harga yang kecil untuk dibayar.

Jadi Kapan Anda Harus Menggunakan Atribut Kelas Python?

Atribut kelas itu rumit, tetapi mari kita lihat beberapa kasus ketika mereka akan berguna:

  1. Menyimpan konstanta . Karena atribut kelas dapat diakses sebagai atribut kelas itu sendiri, sering kali bagus untuk menggunakannya untuk menyimpan konstanta kelas-lebar, khusus kelas. Sebagai contoh:

     class Circle(object): pi = 3.14159 def __init__(self, radius): self.radius = radius def area(self): return Circle.pi * self.radius * self.radius Circle.pi ## 3.14159 c = Circle(10) c.pi ## 3.14159 c.area() ## 314.159
  2. Mendefinisikan nilai default . Sebagai contoh sepele, kita mungkin membuat daftar terbatas (yaitu, daftar yang hanya dapat menampung sejumlah elemen tertentu atau lebih sedikit) dan memilih untuk memiliki batas default 10 item:

     class MyClass(object): limit = 10 def __init__(self): self.data = [] def item(self, i): return self.data[i] def add(self, e): if len(self.data) >= self.limit: raise Exception("Too many elements") self.data.append(e) MyClass.limit ## 10

    Kami kemudian dapat membuat instance dengan batas spesifiknya sendiri, juga, dengan menetapkan atribut limit instance.

     foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10

    Ini hanya masuk akal jika Anda ingin instance MyClass khas Anda hanya menampung 10 elemen atau kurang—jika Anda memberikan semua instance Anda batasan yang berbeda, maka limit harus berupa variabel instan. (Ingat, meskipun: berhati-hatilah saat menggunakan nilai yang bisa berubah sebagai default Anda.)

  3. Melacak semua data di semua instance dari kelas tertentu . Ini agak spesifik, tetapi saya bisa melihat skenario di mana Anda mungkin ingin mengakses sepotong data yang terkait dengan setiap instance yang ada dari kelas tertentu.

    Untuk membuat skenario lebih konkret, misalkan kita memiliki kelas Person , dan setiap orang memiliki name . Kami ingin melacak semua nama yang telah digunakan. Salah satu pendekatan mungkin untuk mengulangi daftar objek pengumpul sampah, tetapi lebih mudah menggunakan variabel kelas.

    Perhatikan bahwa, dalam kasus ini, names hanya akan diakses sebagai variabel kelas, jadi default yang dapat diubah dapat diterima.

     class Person(object): all_names = [] def __init__(self, name): self.name = name Person.all_names.append(name) joe = Person('Joe') bob = Person('Bob') print Person.all_names ## ['Joe', 'Bob']

    Kami bahkan dapat menggunakan pola desain ini untuk melacak semua instance yang ada dari kelas tertentu, bukan hanya beberapa data terkait.

     class Person(object): all_people = [] def __init__(self, name): self.name = name Person.all_people.append(self) joe = Person('Joe') bob = Person('Bob') print Person.all_people ## [<__main__.Person object at 0x10e428c50>, <__main__.Person object at 0x10e428c90>]
  4. Kinerja (semacam ... lihat di bawah).

Terkait: Praktik Terbaik dan Tip Python oleh Pengembang Toptal

Dibawah tenda

Catatan: Jika Anda mengkhawatirkan kinerja pada level ini, Anda mungkin tidak ingin menggunakan Python sejak awal, karena perbedaannya akan berada di urutan sepersepuluh milidetik—tetapi masih menyenangkan untuk dijelajahi sedikit, dan membantu demi ilustrasi.

Ingat bahwa namespace kelas dibuat dan diisi pada saat definisi kelas. Itu berarti bahwa kita hanya melakukan satu tugas— pernah —untuk variabel kelas tertentu, sementara variabel instan harus ditetapkan setiap kali instans baru dibuat. Mari kita ambil contoh.

 def called_class(): print "Class assignment" return 2 class Bar(object): y = called_class() def __init__(self, x): self.x = x ## "Class assignment" def called_instance(): print "Instance assignment" return 2 class Foo(object): def __init__(self, x): self.y = called_instance() self.x = x Bar(1) Bar(2) Foo(1) ## "Instance assignment" Foo(2) ## "Instance assignment"

Kami menetapkan ke Bar.y hanya sekali, tetapi instance_of_Foo.y pada setiap panggilan ke __init__ .

Sebagai bukti lebih lanjut, mari kita gunakan disassembler Python:

 import dis class Bar(object): y = 2 def __init__(self, x): self.x = x class Foo(object): def __init__(self, x): self.y = 2 self.x = x dis.dis(Bar) ## Disassembly of __init__: ## 7 0 LOAD_FAST 1 (x) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (x) ## 9 LOAD_CONST 0 (None) ## 12 RETURN_VALUE dis.dis(Foo) ## Disassembly of __init__: ## 11 0 LOAD_CONST 1 (2) ## 3 LOAD_FAST 0 (self) ## 6 STORE_ATTR 0 (y) ## 12 9 LOAD_FAST 1 (x) ## 12 LOAD_FAST 0 (self) ## 15 STORE_ATTR 1 (x) ## 18 LOAD_CONST 0 (None) ## 21 RETURN_VALUE

Ketika kita melihat kode byte, sekali lagi jelas bahwa Foo.__init__ harus melakukan dua tugas, sedangkan Bar.__init__ hanya satu.

Dalam praktiknya, seperti apa sebenarnya perolehan ini? Saya akan menjadi orang pertama yang mengakui bahwa tes waktu sangat bergantung pada faktor-faktor yang seringkali tidak dapat dikendalikan dan perbedaan di antara mereka seringkali sulit untuk dijelaskan secara akurat.

Namun, saya pikir cuplikan kecil ini (dijalankan dengan modul timeit Python) membantu menggambarkan perbedaan antara variabel kelas dan instance, jadi saya tetap menyertakannya.

Catatan: Saya menggunakan MacBook Pro dengan OS X 10.8.5 dan Python 2.7.2.

inisialisasi

 10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s

Inisialisasi Bar lebih cepat lebih dari satu detik, jadi perbedaannya di sini tampaknya signifikan secara statistik.

Jadi mengapa hal ini terjadi? Satu penjelasan spekulatif : kami melakukan dua tugas di Foo.__init__ , tetapi hanya satu di Bar.__init__ .

Penugasan

 10000000 calls to `Bar(2).y = 15`: 6.232s 10000000 calls to `Foo(2).y = 15`: 6.855s 10000000 `Bar` assignments: 6.232s - 4.940s = 1.292s 10000000 `Foo` assignments: 6.855s - 6.043s = 0.812s

Catatan: Tidak ada cara untuk menjalankan kembali kode penyiapan Anda pada setiap percobaan dengan waktu, jadi kami harus menginisialisasi ulang variabel kami pada percobaan kami. Baris kedua waktu mewakili waktu di atas dengan pengurangan waktu inisialisasi yang dihitung sebelumnya.

Dari atas, sepertinya Foo hanya membutuhkan sekitar 60% selama Bar untuk menangani tugas.

Mengapa demikian? Satu penjelasan spekulatif : ketika kita menetapkan ke Bar(2).y , pertama-tama kita melihat di namespace instance ( Bar(2).__dict__[y] ), gagal menemukan y , dan kemudian mencari di namespace kelas ( Bar.__dict__[y] ), kemudian membuat tugas yang tepat. Saat kita menetapkan ke Foo(2).y , kita melakukan pencarian setengah dari lebih banyak, karena kita segera menetapkan ke namespace instance ( Foo(2).__dict__[y] ).

Singkatnya, meskipun peningkatan kinerja ini tidak menjadi masalah dalam kenyataan, tes ini menarik pada tingkat konseptual. Jika ada, saya harap perbedaan ini membantu menggambarkan perbedaan mekanis antara kelas dan variabel instan.

Kesimpulannya

Atribut kelas tampaknya kurang dimanfaatkan dengan Python; banyak programmer memiliki kesan berbeda tentang cara kerja mereka dan mengapa mereka bisa membantu.

Pendapat saya: Variabel kelas Python memiliki tempatnya di dalam sekolah kode yang baik. Ketika digunakan dengan hati-hati, mereka dapat menyederhanakan berbagai hal dan meningkatkan keterbacaan. Tetapi ketika dengan sembarangan dilemparkan ke kelas tertentu, mereka pasti akan membuat Anda tersandung.

Lampiran : Variabel Instance Pribadi

Satu hal yang ingin saya sertakan tetapi tidak memiliki titik masuk alami…

Python tidak memiliki variabel pribadi yang bisa dibicarakan, tetapi hubungan menarik lainnya antara penamaan kelas dan instance hadir dengan mangling nama.

Dalam panduan gaya Python, dikatakan bahwa variabel pseudo-private harus diawali dengan garis bawah ganda: '__'. Ini bukan hanya tanda bagi orang lain bahwa variabel Anda dimaksudkan untuk diperlakukan secara pribadi, tetapi juga cara untuk mencegah akses ke variabel tersebut. Inilah yang saya maksud:

 class Bar(object): def __init__(self): self.__zap = 1 a = Bar() a.__zap ## Traceback (most recent call last): ## File "<stdin>", line 1, in <module> ## AttributeError: 'Bar' object has no attribute '__baz' ## Hmm. So what's in the namespace? a.__dict__ {'_Bar__zap': 1} a._Bar__zap ## 1

Lihat itu: atribut instance __zap secara otomatis diawali dengan nama kelas untuk menghasilkan _Bar__zap .

Meskipun masih dapat diatur dan dapat diambil menggunakan a._Bar__zap , mangling nama ini adalah sarana untuk membuat variabel 'pribadi' karena mencegah Anda dan orang lain mengaksesnya secara tidak sengaja atau karena ketidaktahuan.

Sunting: seperti yang ditunjukkan Pedro Werneck dengan ramah, perilaku ini sebagian besar dimaksudkan untuk membantu subkelas. Dalam panduan gaya PEP 8, mereka melihatnya melayani dua tujuan: (1) mencegah subclass mengakses atribut tertentu, dan (2) mencegah bentrokan namespace di subclass ini. Meskipun berguna, mangling variabel tidak boleh dilihat sebagai undangan untuk menulis kode dengan asumsi perbedaan publik-swasta, seperti yang ada di Java.

Terkait: Menjadi Lebih Maju: Hindari 10 Kesalahan Paling Umum yang Dilakukan Pemrogram Python