Modul 2 Struktur Data: Pengantar OOP

Modul 2 Struktur Data: Pengantar OOP

Kembali ke Struktur Data (dengan Python)

Pada praktikum kali ini, kita akan membahas tentang class, yang nantinya akan kita gunakan untuk membuat berbagai jenis struktur data. Sekaligus, kita juga akan membahas tentang object-oriented programming atau OOP (pemrograman berorientasi objek atau PBO), yaitu semacam “paradigma pemrograman” (gaya pemrograman) di mana kita sering berurusan dengan class.

Intinya, hari ini kita akan membahas tentang class dan serba-serbi (filosofi) penggunaannya.

Apa itu class? Apa itu OOP?

Di pertemuan sebelumnya, ketika belajar tentang tipe data di Python, kita sering menjumpai nama tipe data disertai istilah class. Sebelum memahami apa itu class, kita bisa paham dulu tentang konsep “objek”.

Di Python (dan banyak bahasa pemrograman lainnya yang “mendukung OOP”), sebuah “objek” adalah sesuatu yang bisa memiliki variabel-variabel tersendiri (disebut atribut) serta fungsi-fungsi tersendiri (disebut method) di bawah satu nama yang sama (yaitu objek tersebut).

Kemudian, sebuah class adalah semacam blueprint untuk membuat objek. Ketika kita ingin membuat objek, kita harus membuat definisi class nya terlebih dahulu sebagai blueprint untuk objek tersebut. Barulah, setelah definisi class nya ada, kita bisa membuat objek sebanyak-banyaknya dari class yang sama.

Sebagai blueprint untuk membuat objek, suatu definisi class mencakupi atribut serta method yang akan terdefinisi untuk objek yang akan dibuat. Artinya, semua objek yang dibuat dari class yang sama itu akan memiliki “struktur” yang sama, baik variabel-variabel maupun fungsi-fungsi yang terkandung di dalam tiap objek.

(Itulah mengapa tipe data dianggap sebagai class di Python. Misalnya, untuk tipe data str, yaitu <class 'str'>, semua string di Python tentunya “memiliki sifat yang sama”, seperti bisa di-format dengan method .format)

Agar lebih paham, mari kita coba membuat class pertama kita, yaitu class Orang, untuk menyimpan data orang yang terdiri dari nama dan umur. Kemudian, kita akan membuat beberapa objek, yaitu beberapa Orang, yang masing-masing bisa memiliki data nama dan umur tersendiri.

class Orang:
    def __init__(self, nama, umur):
        self.nama = nama
        self.umur = umur

Pada definisi class Orang di atas, kita baru merancang atribut apa saja yang akan terkandung dalam objek, yaitu nama dan umur.

  • Pada baris pertama, kita menuliskan kata class untuk memulai suatu definisi class baru, diikuti dengan nama class nya (di sini namanya Orang).
  • Pada baris kedua, kita memulai definisi suatu method istimewa yang bernama __init__ yang dimulai dan diakhiri dengan dua garis bawah. Method yang satu ini harus selalu ada di tiap definisi class, dan istilahnya adalah constructor. Argumen yang masuk ke dalam method ini adalah self yang merujuk ke “diri sendiri” (objek yang bersangkutan), kemudian dua atribut yang bisa ditentukan ketika objek dibuat, yaitu nama dan umur
  • Di dalam definisi __init__ di atas (baris ketiga dan keempat), nilai self.nama dan self.umur akan dipasangkan menjadi nama dan umur yang “masuk ke dalam method” (yaitu ditentukan ketika objek dibuat).

Kalau baru pertama kali lihat, mungkin syntax definisi class rasanya sangat aneh dan asing. Tidak masalah, itu normal. Ketiknya pelan-pelan saja. Kalau belum begitu paham, juga tidak masalah, ikuti saja. Perlahan, kita akan terus-menerus memberi tambahan ke definisi class Orang tersebut agar lebih paham.

Semoga menjadi lebih jelas setelah melihat syntax pembuatan objek:

orang1 = Orang("Bisma", 19)
orang2 = Orang("Vero", 20)

Kemudian, kita bisa melihat atribut objek seperti berikut:

print(orang1.nama)
print(orang1.umur)
Bisma
19
print(orang2.nama)
print(orang2.umur)
Vero
20

Perhatikan bahwa masing-masing atribut diakses melalui objek yang bersangkutan. Terlihat kegunaan objek sebagai penampung beberapa variabel (atribut) di bawah satu nama yang sama.

Selain melihat, tentunya kita juga bisa melakukan assignment:

orang1.umur = 21
print(orang1.umur)
21

Bahkan, kita bisa melakukan variasi assignment lainnya seperti biasa, misalnya +=

orang1.umur += 3
print(orang1.umur)
24

Kalau dirasa perlu, kita dapat membuat fungsi yang akan menerima suatu objek Orang lalu akan mengubah data umur.`

def ulangtahun(orang):
    orang.umur += 1

Sehingga, bisa digunakan seperti berikut:

ulangtahun(orang1)
print(orang1.umur)
25

Perhatikan bahwa objek di Python bersifat pass-by-reference! Artinya, apabila suatu objek dimasukkan ke dalam fungsi, kemudian dimodifikasi di dalam fungsi tersebut, maka modifikasi tersebut juga berdampak hingga di luar fungsi.

Definisi fungsi ulangtahun yang telah kita buat di atas sebenarnya bisa dimasukkan ke dalam definisi class Orang sebagai suatu method.

class Orang:
    def __init__(self, nama, umur):
        self.nama = nama
        self.umur = umur
    def ulangtahun(self):
        self.umur += 1

Perhatikan, ini adalah pendefinisian ulang! Ini adalah definisi baru untuk class Orang. Sedangkan, objek-objek yang sudah kita buat sebelumnya masih menganut definisi yang lama. Sehingga, setelah ini, kita harus membuat ulang objek agar mengikuti definisi class Orang yang baru.

Perhatikan juga, ada sedikit perbedaan istilah pada fungsi ulangtahun: tadinya, objek yang masuk itu kita sebut orang, sekarang kita sebut self. Istilah self ini memang sudah menjadi kebiasaan di Python untuk merujuk ke diri sendiri, yaitu objek yang bersangkutan. Tiap definisi method selalu harus diawali dengan masuknya objek yang bersangkutan (yang biasa disebut self), sudah menjadi formalitas di Python.

Itulah mengapa, di definisi __init__ seolah-olah ada tiga variabel yang masuk yaitu self, nama, dan umur, meskipun yang diperlukan ketika membuat objeknya hanyalah nama dan umur.

Mari kita buat ulang orang1:

orang1 = Orang("Bisma", 19)

Kita bisa melihat atributnya:

print(orang1.nama)
print(orang1.umur)
Bisma
19

Kemudian, kita bisa menggunakan method ulangtahun yang telah kita buat, lalu melihat data umur terbaru:

orang1.ulangtahun()
print(orang1.umur)
20

Penggunaan method memang seperti itu, sangat mirip dengan mengakses atribut, bedanya adalah bahwa method berupa fungsi. Di sini, kita bisa melihat, baik atribut maupun method suatu objek itu sama-sama berada di bawah satu nama yang sama, yaitu objek yang bersangkutan (di sini, baik atribut umur maupun method ulangtahun diakses melalui orang1).

Kalau mau, kita bisa melakukannya lagi:

orang1.ulangtahun()
print(orang1.umur)
21

Tentu saja, kegunaan class tidak sebatas itu. Bahkan, ada semacam “paradigma pemrograman” (gaya pemrograman) di mana kita sering berurusan dengan class, yang disebut OOP. Agar lebih paham juga tentang class dan kegunaannya, kita akan mempelajari dasar-dasar OOP, yang tercakup oleh empat pilar (tiang) OOP.

Empat pilar OOP

Empat pilar OOP adalah:

  1. Encapsulation (pembungkusan)
  2. Abstraction (abstraksi; kebalikan dari “mendetail”)
  3. Inheritance (pewarisan sifat)
  4. Polymorphism (“banyak bentuk”)

Istilah prinsip polymorphism memang sulit diterjemahkan. Kita akan membahas masing-masing keempat prinsip OOP tersebut.

Encapsulation dan Abstraction

Sejauh ini, kita sudah merasakan bagaimana variabel (atribut) dan fungsi (method) sama-sama berada di bawah satu nama yang sama, yaitu objek yang bersangkutan. Seolah-olah, atribut dan method tersebut dibungkus ke dalam objek tersebut. Inilah yang dinamakan prinsip encapsulation atau pembungkusan.

Namun, ada juga konsep data hiding, di mana atribut objek sebaiknya diakses dan dimodifikasi melalui method saja. Method untuk memperoleh (mengakses) nilai atribut tertentu disebut getter, dan method untuk memasang nilai baru untuk atribut tertentu disebut setter.

Prinsip data hiding seringkali dianggap bagian dari prinsip encapsulation (tetapi terkadang dianggap bagian dari abstraction yang akan kita bahas selanjutnya).

Kita akan mendefinisikan ulang class Orang agar memiliki getter dan setter untuk atribut umur.

class Orang:
    def __init__(self, nama, umur):
        self.nama = nama
        self.umur = umur
    def ulangtahun(self):
        self.umur += 1
    def get_umur(self):
        return self.umur
    def set_umur(self, baru):
        self.umur = baru

Perhatikan bahwa method get_umur melakukan return. Penggunaannya akan mirip dengan fungsi seperti biasanya. Kemudian, method set_umur akan menerima satu input di dalam kurungnya (sedangkan self hanya untuk formalitas).

Kita bisa membuat objek seperti biasa…

orang1 = Orang("Bisma", 19)

Lalu kita bisa melihat umurnya seperti ini:

print(orang1.get_umur())
19

Atau bahkan kita bisa membuat variabel baru yang menyimpan umur yang diperoleh:

berapa_tahun = orang1.get_umur()
print(berapa_tahun)
19

Kemudian, kita bisa memasang nilai baru untuk atribut umur:

orang1.set_umur(30)

Lalu memperoleh kembali umur yang baru:

orang1.get_umur()
30

Sebenarnya, tujuan getter dan setter adalah untuk berjaga-jaga agar tidak terjadi hal yang aneh. Misalnya, saat ini, kita masih bisa memasang umur menjadi negatif:

orang1.umur = -5
print(orang1.umur)
-5

Kita dapat menambahkan if statement pada definisi method set_umur di definisi class Orang untuk mencegah umur dipasang menjadi negatif:

class Orang:
    def __init__(self, nama, umur):
        self.nama = nama
        self.umur = umur
    def ulangtahun(self):
        self.umur += 1
    def get_umur(self):
        return self.umur
    def set_umur(self, baru):
        if baru >= 0:
            self.umur = baru
        else:
            print("error: umur tidak bisa negatif")

Sehingga, setelah membuat objek, kita bisa mencoba:

orang1 = Orang("Bisma", 19)
orang1.set_umur(-5)
error: umur tidak bisa negatif

Dengan begitu, data umur masih aman:

orang1.get_umur()
19

Sedangkan, pemasangan umur menjadi bilangan yang tidak negatif tetap berjalan dengan lancar:

orang1.set_umur(25)
print(orang1.get_umur())
25

Apakah kemudian kita masih bisa menuliskan misalnya orang1.umur = -5? Masih bisa, tetapi setidaknya, sekarang dengan adanya getter dan setter untuk atribut umur, kita bisa menjadikan kebiasaan agar selalu menggunakan get_umur dan set_umur ketika ingin berurusan dengan data umur, tidak lagi melalui self.umur, agar terjamin tidak akan terjadi keanehan seperti itu. Biasanya, istilahnya, atribut umur disebut private, karena diharapkan tidak bisa diakses dari luar secara langsung, hanya boleh melalui method.

Bahkan, kita dapat menggunakan getter dan setter di dalam definisi method lainnya. Contohnya, yang tadinya method ulangtahun didefinisikan sebagai self.umur += 1, kita bisa menggantikannya dengan get_umur dan set_umur:

class Orang:
    def __init__(self, nama, umur):
        self.nama = nama
        self.umur = umur
    def ulangtahun(self):
        self.set_umur(self.get_umur() + 1)
    def get_umur(self):
        return self.umur
    def set_umur(self, baru):
        if baru >= 0:
            self.umur = baru
        else:
            print("error: umur tidak bisa negatif")

Pada definisi baru di atas untuk method ulangtahun, konsepnya sebagai berikut:

  1. Peroleh umur saat ini dengan self.get_umur
  2. Tambah satu
  3. Hasil yang baru itu dijadikan umur yang baru menggunakan self.set_umur

Saat ini, orang1 masih menggunakan definisi method ulangtahun yang lama. Mari kita buat objek baru dari definisi class Orang yang baru bernama orang3, agar bisa dibandingkan:

orang3 = Orang("Bisma", 19)
orang1.set_umur(19) # kita samakan dulu umurnya

Kemudian, kita gunakan method ulangtahun pada keduanya:

orang1.ulangtahun()
orang3.ulangtahun()

Kita bisa melihat umur baru masing-masing:

print(orang1.get_umur())
print(orang3.get_umur())
20
20

Ternyata hasilnya sama. Artinya, kedua cara mendefinisikan method ulangtahun itu memberikan hasil yang sama.

Perhatikan bahwa, dari segi penggunaan, untuk menambahkan satu ke data umur, kita tinggal memanggil method ulangtahun. Kita tidak perlu memikirkan internalnya seperti apa. Bahkan, kita bisa mengubah definisinya secara internal, tetapi cara penggunaannya dari luar tetap sama.

Selain itu, untuk memasang data umur baru tanpa pusing, kita bisa langsung menggunakan set_umur. Bahkan, kita tidak perlu mengkhawatirkan kasus umur negatif; method tersebut bisa langsung menanganinya. Sehingga, kapanpun kita ingin memasang data umur yang baru, kita tidak perlu lagi membuat if statement untuk memastikan umurnya tidak negatif, karena sudah ditangani oleh set_umur.

Kedua contoh method di atas menggambarkan bagaimana method bisa sangat mempermudah proses pemrograman kita dengan objek. Prinsip abstraction menekankan penggunaan method dengan cara seperti itu agar kita tidak perlu terlalu memusingkan detailnya. Misalnya, kita tidak perlu memusingkan cara mendefinisikan method ulangtahun, dan kita tidak perlu memusingkan kasus umur negatif berkat adanya method set_umur, pokoknya tinggal pakai. Lagipula, maksudnya “abstraksi” adalah kebalikan dari “mendetail”.

Selain tidak pusing, manfaat lain dari abstraction adalah, kapanpun kita mau, kita bisa memodifikasi definisi method di definisi class nya saja, tanpa harus mengubah kode yang menggunakan method tersebut.

Bayangkan apabila tidak ada method ulangtahun, sehingga kita menjadi harus mengubah self.umur += 1 menjadi self.set_umur(self.get_umur() + 1) di mana-mana. Betapa ribetnya.

Inheritance (pewarisan sifat)

Sebelum belajar tentang inheritance, mari kita buat satu method lagi yaitu perkenalan:

class Orang:
    def __init__(self, nama, umur):
        self.nama = nama
        self.umur = umur
    def ulangtahun(self):
        self.set_umur(self.get_umur() + 1)
    def get_umur(self):
        return self.umur
    def set_umur(self, baru):
        if baru >= 0:
            self.umur = baru
        else:
            print("error: umur tidak bisa negatif")
    def perkenalan(self):
        print("Halo, nama saya " + self.nama + " dan umur saya " + str(self.umur) + " tahun.")

Seperti biasa, kita bisa membuat objek:

orang1 = Orang("Bisma", 19)
orang2 = Orang("Vero", 20)

Kemudian, kita bisa memanggil method perkenalan

orang1.perkenalan()
orang2.perkenalan()
Halo, nama saya Bisma dan umur saya 19 tahun.
Halo, nama saya Vero dan umur saya 20 tahun.

Lalu, misalnya, kita ingin membuat class baru yaitu class Mahasiswa, yang akan memiliki atribut tambahan yaitu NPM.

Tentunya, mahasiswa adalah orang, sehingga kita harapkan bahwa semua yang bisa dilakukan oleh objek dari class Orang juga bisa dilakukan oleh objek dari class Mahasiswa.

Untungnya, daripada harus copy-paste semua method yang ada di class Orang ke dalam definisi class Mahasiswa, kita tinggal memanfaatkan inheritance (pewarisan sifat), dengan syntax yang bisa dilihat di baris pertama di kode berikut:

class Mahasiswa(Orang):
    def __init__(self, nama, umur, NPM):
        self.nama = nama
        self.umur = umur
        self.NPM = NPM

Sesingkat itu! Kita tinggal menyediakan constructor __init__ yang baru yang lebih sesuai untuk class Mahasiswa, karena adanya atribut baru yaitu NPM. Semua method lainnya akan tetap dimiliki oleh objek dari class Mahasiswa karena sudah diwariskan dari class Orang, hanya dengan menuliskan class Mahasiswa(Orang) pada baris pertama definisi class Mahasiswa.

class yang asli (di sini class Orang) biasa disebut parent class, base class, atau superclass, sedangkan class yang mewariskan (di sini class Mahasiswa) biasa disebut child class, derived class, atau subclass.

Kemudian, pembuatan objek dari class Mahasiswa dilakukan seperti biasa (jangan lupa, kali ini ada tiga atribut):

mhs1 = Mahasiswa("Bisma", 19, 2106635581)

Seperti biasa, kita bisa lihat isi atributnya satu per satu:

print(mhs1.nama)
print(mhs1.umur)
print(mhs1.NPM)
Bisma
19
2106635581

Semua method yang dimiliki oleh objek Orang itu juga dimiliki oleh objek Mahasiswa. Misalnya, kita bisa menggunakan method ulangtahun dan get_umur:

mhs1.ulangtahun()
print(mhs1.get_umur())
20

Kita juga bisa melakukan perkenalan

mhs1.perkenalan()
Halo, nama saya Bisma dan umur saya 20 tahun.

Namun, isi perkenalannya sama persis seperti objek Orang, bahkan tidak ada keterangan NPM. Bagaimana kalau kita mau mahasiswa melakukan perkenalan dengan NPM juga? Apakah kita bisa memodifikasi method ini khusus untuk class Mahasiswa? Jawabannya adalah bisa, berkat prinsip polymorphism.

Polymorphism (“banyak bentuk”)

Setelah melakukan inheritance, seandainya ada method yang diwaris yang dirasa perlu diubah atau dibedakan dari parent class, kita tinggal mendefinisikan ulang method tersebut di dalam definisi child class yang bersangkutan.

Misalnya, kita bisa mendefinisikan ulang method perkenalan di dalam definisi class Mahasiswa agar berbeda dengan perkenalan di class Orang:

class Mahasiswa(Orang):
    def __init__(self, nama, umur, NPM):
        self.nama = nama
        self.umur = umur
        self.NPM = NPM
    def perkenalan(self):
        print("Perkenalkan, saya " + self.nama + " dengan NPM " + str(self.NPM) )

Kita sudah memiliki orang1 sebagai objek dari class Orang, sehingga bisa kita bandingkan dengan objek dari class Mahasiswa yang perlu kita buat ulang:

mhs1 = Mahasiswa("Bisma", 19, 2106635581)

Sekarang kita lakukan perkenalan untuk masing-masing:

orang1.perkenalan()
mhs1.perkenalan()
Halo, nama saya Bisma dan umur saya 19 tahun.
Perkenalkan, saya Bisma dengan NPM 2106635581

Hasilnya berbeda, sesuai harapan. Namun, nama method nya tetap sama, yaitu perkenalan. Seolah-olah, method perkenalan ini adalah “method yang sama” tetapi “memiliki bentuk yang berbeda-beda”, yaitu berbeda antara di class Orang dengan class Mahasiswa.

Bahkan, kalau mau, kita bisa membuat child class yang baru lagi dari class Orang, dan mendefinisikan ulang atau “menimpa” lagi method perkenalan untuk child class tersebut. Sehingga, method perkenalan ini seperti memiliki banyak bentuk.

“Banyak bentuk” itulah yang dimaksud dengan polymorphism. Kita bisa melakukan inheritance berkali-kali, kemudian “menimpa” suatu method pada child class dengan definisi yang berbeda daripada di parent class.

Penerapan lain dari prinsip polymorphism adalah fitur yang bernama operator overloading, yang kebetulan dimiliki oleh Python dan sejumlah “bahasa OOP” lainnya (bahasa yang “mendukung OOP”, yaitu memiliki fitur class, inheritance dan sebagainya sesuai dengan empat pilar OOP).

Operator overloading

Misalnya kita membuat class Pecahan yang terdiri dari atribut pembilang dan penyebut:

class Pecahan:
    def __init__(self, pembilang, penyebut):
        self.pembilang = pembilang
        self.penyebut = penyebut

Kita bisa membuat pecahan setengah seperti berikut:

frac1 = Pecahan(1, 2)

Kita bisa melihat isi atribut pembilang dan penyebut:

print(frac1.pembilang)
print(frac1.penyebut)
1
2

Misalnya kita ada pecahan lain…

frac2 = Pecahan(3, 5)

… alangkah indahnya kalau kita bisa menjumlahkannya begitu saja…

frac1 + frac2
TypeError: unsupported operand type(s) for +: 'Pecahan' and 'Pecahan'

Terjadi error, karena saat ini, operator + belum ada artinya untuk objek Pecahan.

Akan tetapi, ada method istimewa yang bisa kita definisikan agar operator + menjadi terdefinisi, lho! Namanya adalah __add__.

Secara matematis, penjumlahan pecahan bisa dituliskan seperti berikut:

\[\frac{a}{b} + \frac{c}{d} = \frac{ad + bc}{bd}\]

Sehingga, kita bisa mendefinisikan method __add__ sebagai berikut:

class Pecahan:
    def __init__(self, pembilang, penyebut):
        self.pembilang = pembilang
        self.penyebut = penyebut
    def __add__(self, pecahan2):
        a = self.pembilang
        b = self.penyebut
        c = pecahan2.pembilang
        d = pecahan2.penyebut
        atas = a*d + b*c
        bawah = b*d
        hasil = Pecahan(atas, bawah)
        return hasil

Lalu, kita bisa membuat ulang kedua pecahan yang tadi, mencoba menjumlahkannya, dan melihat data atribut pembilang dan penyebut di hasil jumlahannya:

frac1 = Pecahan(1, 2)
frac2 = Pecahan(3, 5)
frac3 = frac1 + frac2
print(frac3.pembilang)
print(frac3.penyebut)
11
10

Wow, keren! Hasilnya benar ya!

Selain penjumlahan, kita bisa mendefinisikan banyak operator lainnya untuk class. Pendefinisian operator untuk class disebut operator overloading (“menimpa operator”), dan selalu melibatkan method istimewa atau magic methods (juga disebut dunder methods atau double underscore methods) yang sudah memiliki nama tertentu. Kebetulan, constructor yang dinamakan __init__ juga termasuk magic method.

Kalian bisa membaca lebih lanjut tentang operator overloading dan magic method lainnya di link berikut:

https://www.geeksforgeeks.org/operator-overloading-in-python/