Mengapa kekekalan begitu penting

Halo, Habr!



Hari ini kami ingin menyentuh topik kekekalan dan melihat apakah masalah ini layak mendapat pertimbangan yang lebih serius.



Objek yang tidak berubah adalah fenomena yang sangat kuat dalam pemrograman. Immutability membantu Anda menghindari semua jenis masalah konkurensi dan banyak bug, tetapi memahami konstruksi yang tidak dapat diubah bisa jadi rumit. Mari kita lihat apa itu dan bagaimana menggunakannya.



Pertama, lihat objek sederhana:



class Person {
    public String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
}


Seperti yang Anda lihat, objek Personmengambil satu parameter dalam konstruktornya dan kemudian memasukkannya ke dalam variabel publik name. Karenanya, kita dapat melakukan hal-hal seperti ini:



Person p = new Person("John");
p.name = "Jane";


Sederhana bukan? Baca atau ubah data sesuka kami kapan saja. Tetapi ada beberapa masalah dengan metode ini. Yang pertama dan terpenting dari mereka adalah kita menggunakan variabel di kelas kita name, dan dengan demikian, secara tidak dapat ditarik kembali memasukkan penyimpanan internal kelas ke dalam API publik. Dengan kata lain, tidak ada cara kita dapat mengubah cara nama disimpan di dalam kelas, kecuali kita menulis ulang bagian penting dari aplikasi kita.



Beberapa bahasa (misalnya, C #) menyediakan kemampuan untuk memasukkan fungsi getter untuk mengatasi masalah ini, tetapi di sebagian besar bahasa berorientasi objek, Anda harus bertindak secara eksplisit:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}


Sejauh ini baik. Jika Anda sekarang ingin mengubah penyimpanan internal dari nama tersebut, katakanlah, ke nama depan dan belakang, Anda dapat melakukan ini:



class Person {
    private String firstName;
    private String lastName;
    
    public Person(
        String firstName,
        String lastName
    ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    
    public String getName() {
        return firstName + " " + lastName;
    }
}


Jika Anda tidak menyelidiki masalah serius yang terkait dengan representasi nama seperti itu, jelaslah bahwa API getName()tidak berubah secara eksternal .



Bagaimana dengan pengaturan nama? Apa yang perlu Anda tambahkan agar tidak hanya mendapatkan nama, tetapi juga menyetelnya seperti ini?



class Person {
    private String name;
    
    //...
    
    public void setName(String name) {
        this.name = name;
    }
    
    //...
}


Sekilas terlihat bagus, karena sekarang kita bisa ganti nama lagi. Tetapi ada kelemahan mendasar dalam cara memodifikasi data ini. Ini memiliki dua sisi: filosofis dan praktis.



Mari kita mulai dengan masalah filosofis. Objek tersebut Persondimaksudkan untuk mewakili seseorang. Memang, nama belakang seseorang dapat berubah, tetapi akan lebih baik untuk menamai suatu fungsi untuk tujuan ini changeName, karena nama seperti itu menyiratkan bahwa kita mengubah nama belakang dari orang yang sama. Ini juga harus mencakup logika bisnis untuk mengubah nama belakang seseorang, dan tidak hanya bertindak seperti penyetel. Nama setNamemengarah pada kesimpulan yang sepenuhnya logis bahwa kita dapat secara sukarela-wajib mengubah nama yang disimpan dalam objek person, dan kita tidak akan mendapatkan apa pun untuk itu.



Alasan kedua berkaitan dengan latihan: status yang dapat berubah (data tersimpan yang dapat berubah) rentan terhadap bug. Mari kita ambil objek ini Persondan tentukan antarmuka PersonStorage:



interface PersonStorage {
    public void store(Person person);
    public Person getByName(String name);
}


Catatan: ini PersonStoragetidak menunjukkan dengan tepat di mana objek disimpan: di memori, di disk, atau di database. Antarmuka juga tidak memerlukan implementasi untuk membuat salinan objek yang disimpannya. Oleh karena itu, bug yang menarik mungkin muncul:



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);


Berapa banyak orang saat ini di toko orang? Satu atau dua? Selain itu, jika Anda menerapkan metode ini sekarang getByName, siapa yang akan kembali?



Seperti yang Anda lihat, dua opsi dimungkinkan di sini: apakah itu PersonStorageakan menyalin objek Person, dalam hal ini dua rekaman akan disimpan Person, atau tidak akan melakukan ini, dan hanya akan menyimpan referensi ke objek yang diteruskan; dalam kasus kedua, hanya satu objek dengan nama yang akan disimpan β€œJane”. Penerapan opsi kedua mungkin terlihat seperti ini:



class InMemoryPersonStorage implements PersonStorage {
    private Set<Person> persons = new HashSet<>();

    public void store(Person person) {
        this.persons.add(person);
    }
}


Lebih buruk lagi, data yang disimpan dapat diubah bahkan tanpa memanggil fungsi store. Karena repositori hanya berisi referensi ke objek asli, mengubah nama juga akan mengubah versi yang disimpan:



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");


Jadi, pada dasarnya, bug menyusup ke dalam program kita justru karena kita berhadapan dengan status yang bisa berubah. Tidak ada keraguan bahwa masalah ini dapat dielakkan dengan secara eksplisit menuliskan pekerjaan membuat salinan di penyimpanan, tetapi ada cara yang jauh lebih sederhana: bekerja dengan objek yang tidak dapat diubah. Mari pertimbangkan sebuah contoh:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    
    public Person withName(String name) {
        return new Person(name);
    }
}


Seperti yang Anda lihat, alih-alih metode, setNamekini digunakan metode withNameyang membuat salinan baru dari objek tersebut Person. Jika kita membuat salinan baru setiap kali, maka kita melakukannya tanpa keadaan bisa berubah dan tanpa masalah yang sesuai. Tentu saja, ini disertai dengan beberapa overhead, tetapi kompiler modern dapat mengatasinya dan jika Anda mengalami masalah kinerja, Anda dapat memperbaikinya nanti.



Ingat:

Optimalisasi dini adalah akar dari segala kejahatan (Donald Knuth)


Dapat dikatakan bahwa tingkat persistensi yang mereferensikan objek langsung adalah tingkat persistensi yang rusak, tetapi skenario seperti itu realistis. Kode buruk memang ada, dan keabadian adalah alat yang berharga dalam membantu mencegah kerusakan semacam itu.



Dalam skenario yang lebih kompleks, di mana objek diteruskan melalui beberapa lapisan aplikasi, bug dengan mudah membanjiri kode, dan kekekalan mencegah bug status terjadi. Contoh jenis ini mencakup, misalnya, cache dalam memori atau panggilan fungsi yang tidak teratur.



Bagaimana kekekalan membantu pemrosesan paralel



Area penting lainnya di mana keabadian berguna adalah dalam pemrosesan paralel. Lebih tepatnya, multithreading. Dalam aplikasi multithread, beberapa baris kode dijalankan secara paralel, yang, pada saat yang sama, mengakses area memori yang sama. Pertimbangkan daftar yang sangat sederhana:



if (p.getName().equals("John")) {
    p.setName(p.getName() + "Doe");
}


Kode ini tidak buggy dengan sendirinya, tetapi ketika dijalankan secara paralel ia mulai mendahului dan bisa menjadi berantakan. Lihat seperti apa cuplikan kode di atas dengan komentar:



if (p.getName().equals("John")) {

    //     ,     John
    
    p.setName(p.getName() + "Doe");
}


Ini adalah kondisi balapan. Utas pertama memeriksa apakah namanya sama β€œJohn”, tetapi utas kedua mengubah namanya. Pada saat yang sama, utas pertama terus berfungsi, masih mengasumsikan bahwa namanya sama John.



Tentu saja, penguncian dapat digunakan untuk memastikan bahwa hanya satu utas yang memasuki bagian penting dari kode pada waktu tertentu, namun, ini bisa menjadi hambatan. Namun, jika objek tidak dapat diubah, skenario seperti itu tidak dapat berkembang, karena objek yang sama selalu disimpan di p. Jika utas lain ingin memengaruhi perubahan, itu membuat salinan baru yang tidak akan ada di utas pertama.



Hasil



Pada dasarnya, saya akan menyarankan untuk selalu memastikan bahwa status yang bisa berubah diminimalkan dalam aplikasi Anda. Jika Anda menggunakannya, batasi dengan baik dengan API yang dirancang dengan baik, jangan biarkan bocor ke area lain dari aplikasi. Semakin sedikit potongan kode yang Anda miliki yang berisi status, semakin kecil kemungkinan kesalahan status akan terjadi.



Tentu saja, sebagian besar masalah pemrograman tidak dapat dipecahkan jika Anda tidak menggunakan pernyataan sama sekali. Tetapi jika kita menganggap semua struktur data tidak dapat diubah secara default, maka akan ada lebih sedikit bug acak dalam kode. Jika Anda benar-benar dipaksa untuk memasukkan perubahan ke dalam kode Anda, Anda harus melakukannya dengan hati-hati dan memikirkan konsekuensinya, dan tidak memulai semua kode dengannya.



All Articles