Halo Pecinta Generik & Keamanan Tipe.

Saya sudah lama ingin menulis artikel tentang Generics & Variance tetapi tidak bisa memulai. Sudah ada begitu banyak artikel & video bagus tentang topik ini sehingga saya tidak dapat memikirkan cara untuk menambah nilai. Tapi di sini saya mencoba. Jika Anda seorang ahli, Anda dapat memvalidasi konten di bawah ini atau Jika Anda seorang pemula, Anda mungkin mendapatkan beberapa kesimpulan baru. Ayo mulai.

Generik, Apa Itu?

Konsep Generik pertama kali dirintis dalam bahasa pemrograman yang disebut ML (Meta Language) pada tahun 1973. Pendekatan ini memungkinkan pemrogram untuk menulis fungsi-fungsi umum yang berbeda hanya pada jenis fungsi-fungsi tersebut beroperasi. Ini membantu mengurangi duplikasi kode. Misalnya:

// Standard ML
// A generic swap function
fun swap (y, x) = (x, y)

Di sini fungsi swap bertipe 'a * 'b -> 'b * 'a. Sintaks yang tampaknya aneh ini digunakan untuk mendeskripsikan Tipe Generik dalam ML dan bahasa terinspirasi serupa lainnya. 'a adalah variabel tipe, yang menunjukkan kemungkinan tipe apa pun. Ini bisa berupa Int, Float, String atau Tipe spesifik apa pun yang ditentukan pada saat use case atau instantiasi. Fungsi serupa di Java & Swift adalah:

// Java
public <A, B> Pair<B, A> swap(Pair<A, B> pair) {
    return Pair.create(pair.second, pair.first);
}
// Swift
func swap<A, B>(_ pair: (A, B)) -> (B, A) {
    return (pair.1, pair.0)
}

Di sini Pair<A,B> dalam cuplikan Java adalah Jenis Generik yang dapat menampung dua jenis apa pun. A& B disebut Parameter Tipe. Sebelum Generik kita perlu menulis IntPair, StringPair, int_swap, string_swap secara terpisah untuk setiap tipe unik.

Tipe dengan parameter yang tidak jelas ini disebut Generik di C#, Java, Rust, Swift, TypeScript, dan beberapa lainnya. Mereka dikenal sebagai Parametrik Polimorfisme dalam bahasa seperti ML, Haskell, Scala dan Template dalam C++.

Generik memperkenalkan konsep parameter tipe, yang memungkinkan untuk merancang struktur (kelas, antarmuka) dan fungsi (metode) yang menunda spesifikasi satu atau beberapa tipe hingga waktu pembuatan instance oleh kode klien.

Generik — Bagaimana cara kerjanya?

Berikut beberapa keuntungan menulis Kode Generik:

  1. Pemeriksaan Jenis yang Lebih Kuat pada Waktu Kompilasi.
  2. Memungkinkan kita menulis kode yang dapat diterapkan pada banyak tipe dengan perilaku dasar yang sama. Misalnya: List<T>.
  3. Kode yang dioptimalkan.

Pembaca yang cerdik pasti langsung mempertanyakan poin ke-3 dari daftar di atas. Dioptimalkan? Dalam arti apa? Poin ini memerlukan beberapa koreksi. Ini harus dinyatakan sebagai ByteCode yang Dioptimalkan. Tapi, pertama-tama mari kita bahas dua poin pertama.

Pertimbangkan dua cuplikan kode di bawah ini (di Java).

// Java
List<String> words = new ArrayList<>();
words.add("Hello ");
words.add("world!");
String s = words.get(0) + words.get(1);
System.out.println(s); // Hello World
// Java
List words = new ArrayList();
words.add("Hello ");
words.add("world!");
String s = ((String)words.get(0)) + ((String)words.get(1))
System.out.println(s); // Hello World

Cuplikan ini menghasilkan bytecode yang benar-benar identik. Faktanya Java Compiler, mengubah cuplikan pertama, menggunakan Generik, menjadi cuplikan kedua. Kompiler memastikan bahwa List<String> hanya menyimpan daftar String pada waktu kompilasi sehingga kita tidak menghadapi masalah pada waktu proses. Karena kompiler menangani transmisi otomatis, kompiler menangkis ClassCastException apa pun yang dapat dilemparkan saat Runtime. Jadi keamanan tipe lebih baik. Ada juga soal List<T> dibaca List of T. Untuk metode seperti size() pada List yang kita perlukan hanyalah jumlah elemen dan bukan tipe dasar elemen tersebut. Jadi abstraksi yang lebih baik, bebas dari informasi spesifik Type. Tipuan yang digunakan oleh Java Compiler ini disebut Type Erasure.

Monomorfisasi.

Ada cara lain bagi kompiler untuk beroperasi pada kode generik. Fungsi tersebut dapat dikompilasiuntuk menghasilkan versi khusus fungsi generik yang terpisah. Bingung?. Mari kita ambil contoh yang sangat sederhana di Rust.

// Rust
fn print_hash<T: Hash>(t: &T) {
    println!("The hash is {}", t.hash())
}
print_hash(&true);      // instantiates T = bool
print_hash(&12_i64);    // instantiates T = Int64

Kompiler Java hanya memiliki satu salinan fungsi print_hash, tetapi kompiler Rust sebenarnya menghasilkan dua salinan fungsi ini, satu untuk setiap tipe argumen konkret. Apa keuntungannya? Keuntungannya adalah compiler dapat mengoptimalkan kode sesuai dengan tipe konkret pada gambar. Ini berarti setiap panggilan ke print_hash akan dimasukkan di tempat panggilan tersebut; sebuah panggilan pengiriman statis langsung ke implementasi yang relevan yang menyimpan semua tipuan dalam pemanggilan fungsi. Ini disebut Monomorfisasi — mengubah kode polimorfik menjadi kode monomorfik.

// Compiled Code
__print_hash_bool(&true);
__print_hash_i64(&12_i64);

Jika manfaatnya tampak minimal, pertimbangkan jenis: Array<Bool> & Array<Int>. Kompiler menghasilkan salinan khusus: Array_Bool & Array_Int masing-masing untuk Bool & Int. Kompiler sekarang dapat mengatur tata letak memori sesuai dengan ukuran Bool & Int, menyelaraskannya (jika memungkinkan) dan menghemat ruang. Setiap metode di Array juga menghasilkan kode khusus yang menyimpan tipuan ke pemanggilan metode. Oleh karena itu, kode generiknya secepat dan seefisien memori seolah-olah Anda menulis kode khusus secara langsung. Bagaimana perbandingannya: Sebuah ArrayList<Boolean> di Java, bergantung pada implementasinya, menggunakan setidaknya 16 kali lebih banyak memori dibandingkan di Rust.

Kelihatannya luar biasa bukan; ada juga kelemahannya: Code Bloat&Waktu Kompilasi Lambat. Kompiler membuat salinan independen dari kode generik untuk setiap kombinasi berbeda dari parameter generik yang dipanggil, sehingga menghasilkan ukuran biner yang sangat besar dan berpotensi mengkompilasi fungsi yang sama berkali-kali.

Keamanan Tipe Generik

Jika anda sudah sampai disini maka anda sudah mengetahui apa itu type safety (waktu kompilasi). Menangkap kesalahan bahkan sebelum program dijalankan selalu baik. Generik membantu kami menulis kode aman jenis yang dapat digunakan kembali. Perhatikan contoh dari buku Java Efektif:

// Java
// 1
private final Collection stampsUntyped = ... ;
stampsUntyped.add(new Coin())

// 2
private final Collection<Stamp> stampsTyped = ... ;
stampsTyped.add(new Coin())
                    ^
                    Type Mismatch

Ada dua cara untuk membuat koleksi prangko.

Di 1, kami memiliki koleksi mentah, artinya dapat menampung apa saja. Ada kemungkinan bahwa kita dapat menambahkan instance kelas Coin ke dalamnya. Ini akan berhasil dikompilasi dan akan crash pada saat run time ketika kita mengambil stamp dari koleksi.

Di 2, kita memiliki koleksi Generik, yang parameter generiknya didefinisikan sebagai Stamp. Ini bagus. Ini adalah jenis yang aman. Tidak ada cara bagi kami untuk menambahkan instance Coin ke koleksi prangko. Kompiler datang untuk menyelamatkan kami dan tidak membiarkan kode ini dikompilasi.

Bayangkan seseorang memasukkan java.util.Date instance ke dalam koleksi yang seharusnya hanya berisi java.sql.Date instance. Tipe berparametri akan mencegah hal ini pada waktu kompilasi.

Jika Anda menggunakan tipe mentah, Anda kehilangan semua manfaat keamanan dan ekspresi dari Generik.

___________________________________________________________________

Pertanyaan:
Apa perbedaan antara List & List<Object>?

(Jangan khawatir, kami akan segera menjawabnya.)

___________________________________________________________________

Tunggu sebentar, Apakah kita selalu membutuhkan keamanan tipe?

Pertimbangkan cuplikan kode ini (sekali lagi dari Buku Java Efektif):

// Java
static int numElementsInCommon(Set s1, Set s2) {
    int result = 0;
    for (Object o1 : s1)
        if (s2.contains(o1))
            result++;
    return result;
}

Metode ini numElementsinCommon menghitung jumlah elemen persekutuan antara dua himpunan. Tidak perlu membuat Set masukan ini menjadi generik karena kita hanya mengakses dan membandingkan elemen satu sama lain. Tampaknya kita tidak selalu membutuhkan keamanan tipe.

Tetapi, bisakah kita menjadikannya lebih baik dengan Generik? Jika iya, sebaiknya bagaimana?
Jawabannya adalah Ya, kita bisa,dengan menggunakan Wildcard(?). Ini BUKAN tanda tanya. Di Jawa, ini disebut Wildcard.

Tunggu sebentar, kita akan mendapatkan wildcard sebentar lagi. Pertama kita perlu memahami beberapa prinsip.

Prinsip Subtipe & Substitusi (SP)

Subtyping harus tampak seperti wilayah yang familier atau diketahui dan memang demikian adanya. Ini adalah fitur utama bahasa berorientasi objek seperti Java dan terlalu sering kita gunakan. Ketika kita extend atau implement Tipe tertentu, tipe yang lebih spesifik (yaitu anak/subtipe) dapat ditampung dalam wadah yang lebih besar (yaitu Induk/supertipe). Misalnya:

  • Integer adalah subtipe dari Angka.
  • Ganda adalah subtipe Angka.
  • String adalah subtipe dari Objek.
  • String[] adalah subtipe dari Objek[].
  • ArrayList adalah subtipe dari Daftar.

Selanjutnya, kami akan menyatakan A -> B sebagai A is a subtype of B & A !-> B sebagai A is NOT a subtype of B .

Dan, Prinsip Substitusi mengatakan:

Jika S -> T, berarti istilah apa pun bertipe S dapat aman digunakan dalam konteks di mana istilah bertipe T diharapkan.

Sekarang, apakah Anda ingat nama artikel ini. Saya tahu ini panjang, tetapi ini saatnya memulai perjalanan kita ke bagian yang menarik —Variance. Mari kita pertimbangkan hierarki kelas di bawah ini untuk semua diskusi selanjutnya. Semua contoh akan ada di Java.

// Java
public class Animal {
    String name;
    int troubleMakerRating;
    
    Animal(String name) {
        this.name = name;
        this.troubleMakerRating = new Random().nextInt();
    }
}

public class Dog extends Animal {
    Dog(String name) {
        super(name);
    }
}

public class Cat extends Animal {
    Cat(String name) {
        super(name);
    }
}

Ada kelas Animal. Dog & Cat extend Animal, karena anjing adalah binatang, maka kucing adalah binatang. Setiap Animal memiliki name dan troubleMakerRating. Jadi menurut Prinsip Substitusi (SP), Jika kita mempunyai koleksi Hewan, diperbolehkan menambahkan instance Anjing atau Kucing ke dalamnya:

List<Animal> animals = new ArrayList<Animal>();
animals.add(new Animal("Some Lion"));
animals.add(new Dog("German Shepherd"));
animals.add(new Cat("RagDoll"));

Di sini subtipe ada dalam dua bentuk:
1. Penambahan ke daftar diperbolehkan karena Dog/Cat is a subtype of Animal.
2. Operasi penugasan diperbolehkan karena ArrayList is a subtype of List.

Jadi, jika Dog -> Animal , ArrayList -> List nampaknya sangat masuk akal jika ArrayList<Dog> -> List<Animal>. Mari kita tuliskan ini dalam kode:

List<Animal> animals = new ArrayList<Dog>();

Coba kompilasi potongan kode di atas. Itu tidak akan terjadi. Kompiler melempar Incompatible Types. Kompiler tidak mengizinkan kami mengganti List<Animal> dengan ArrayList<Dog> karena tidak aman.

List<Animal> animals = new ArrayList<Dog>(); // Compilation ERROR !!
animals.add(new Cat("Birman"));

Jika tidak ada kesalahan kompilasi pada baris pertama, maka berdasarkan Prinsip Substitusi, diperbolehkan menambahkan instance Cat ke daftar hewan yang sebenarnya merupakan daftar Dog dan Kucing dan Anjing tidak cocok. Masalah akan terlihat pada saat run time ketika kita menguraikan daftar Hewan yang mengira mereka semua adalah Anjing tetapi muncul Kucing. Ingat aturannya: Kesalahan Waktu Kompilasi Baik, Kesalahan Waktu Proses buruk. Oleh karena itu, Prinsip Substitusi tidak berlaku di sini. yaitu ArrayList<Dog> !-> List<Animal>.

Namun ada juga kasus di mana kita ingin Prinsip Substitusi berfungsi, misalnya kita ingin Kompiler sedikit bersantai karena kita mengetahui apa yang sedang kita lakukan.

void printNames(Collection<Animal> animals) {
    animals.forEach(animal -> System.out.println(animal.name));
}
List<Dog> myDogs = new ArrayList<>();
myDogs.add(new Dog("German Shepherd"));
myDogs.add(new Dog("Bulldog"));
myDogs.add(new Dog("Pug"));

printNames(myDogs); // Compilation ERROR !! Incompatible Types

Fungsi printNamesmengambil daftar hewan dan mencetak namanya. Jadi, kita seharusnya bisa menggunakan kembali fungsi tersebut untuk mencetak daftar nama anjing. Lagipula fungsi printNames hanya mengakses hewan dari daftar dan tidak mencoba menulis ke dalamnya. Tapi tugas kompiler adalah menjaga keamanan, sehingga error terjadi pada printNames(myDogs). Lebih dari Kompiler cerdas!!

Dan, bagaimana jika kita perlu membandingkan dua anjing menggunakan Animal Comparator generik:

// 1
void printNameOfMostTroubleMakerDog(List<Dog> dogs,
                                    Comparator<Dog> comparator) {

    Dog maxTroubleMaker = dogs.get(0);
    for (int i = 1; i < dogs.size(); i++) {
        if (comparator.compare(maxTroubleMaker, dogs.get(i)) > 0) {
            maxTroubleMaker = dogs.get(i);
        }
    }
    System.out.println(maxTroubleMaker.name 
            + " is the most trouble making Dog.");
}
// 2
Comparator<Animal> troubleMakerComparator = Comparator.comparingInt(animal -> animal.troubleMakerRating);
// 3 - Compilation ERROR !! Incompatible Types
printNameOfMostTroubleMakerDog(myDogs, troubleMakerComparator)
  1. printNameOfMostTroubleMakerDogmengambil daftar anjing dan mencetak nama anjing yang paling banyak menimbulkan masalah berdasarkan pembanding masukan.
  2. Kami membuat troubleMakerComparator yang dapat digunakan kembali yang membandingkan peringkat pembuat masalah dari setiap hewan.
  3. Itu tidak dapat dikompilasi, karena printNameOfMostTroubleMakerDogmengharapkan Comparator<Dog> namun kami meneruskan Comparator<Animal>.

Kompiler seharusnya mengizinkan ini. Jika Comparator<Animal> dapat membandingkan dua Hewan, ia pasti dapat membandingkan dua Anjing.

Sekarang timbul pertanyaan, jika kita tahu bahwa beberapa substitusi seperti di atas aman, bukankah hal ini juga bisa kita sampaikan ke compiler? Jika jawaban untuk setiap pertanyaan adalah Tidak, artikel ini tidak diperlukan. 😛
Ingat WildCard ( ?). Ya, kami akan menggunakannya sekarang.

Perbedaan

Varians mengacu pada bagaimana subtipe antara tipe yang lebih kompleks berhubungan dengan subtipe antar komponennya. Misalnya, bagaimana seharusnya daftar Cats berhubungan dengan daftar Animals, Atau bagaimana seharusnya suatu fungsi yang mengembalikan Cat berhubungan dengan fungsi yang mengembalikan Animal.

Ada empat kemungkinan varian:

  • Kovariansi: Mempertahankan pengurutan relasi subtipe. yaitu
    jika A -> B, maka Box<A> -> Box<B>
  • Contravariance: Membalikkan urutan relasi subtipe. yaitu
    jika A -> B, maka Box<B> -> Box<A>
  • Bivariansi: Jika kedua hal di atas berlaku. yaitu
    jika A -> B, maka Box<A> -> Box<B> & Box<B> -> Box<A>
  • Invariansi: Baik Kovariansi atau Kontravariansi tidak berlaku.

Untuk mengatasi masalah substitusi yang kita hadapi saat menggunakan printNames & printNameOfMostTroubleMakerDog, kita akan menggunakan Covariance & Contravariance masing-masing.

Kovarian

Jika entah bagaimana, ArrayList<Dog> menjadi subtipe dari List<Animal>, fungsi printNames menjadi dapat digunakan kembali untuk daftar apa pun yang berisi tipe yang memperluas Hewan. Beginilah Cara kita melakukanya:

void printNames(Collection<? extends Animal> animals) {
    ...
}

printNames(myDogs); // Compiles cleanly

Sekarang printNames(myDogs); memeriksa dan mengkompilasi tipe. Perhatikan perubahan tanda tangan. printNames tidak menerima Collection<Animal> lagi melainkan Collection<? extends Animal>. Saat kita menggunakan ? extends, pada dasarnya kita memberi tahu kompiler bahwa kumpulan item yang Tipenya terikat atas Animal diterima oleh fungsi printNames.

Ini melakukan dua hal:

  1. Itu menghasilkan List<Dog> -> Collection<Animal>. Ini disebut Kovarian.
  2. Dan, di dalam printNames kita TIDAK dapat menambahkan item apa pun ke koleksi, namun kita dapat menguraikan atau mengakses item di luar daftar. Kami tidak dapat menambahkan karena mungkin saja daftar ini berupa daftar anjing (daftar homogen) atau daftar beberapa hewan (daftar heterogen). Karena kami tidak yakin apa daftar dasarnya, kompiler tidak mengizinkan penambahan apa pun ke koleksi.

Kita juga bisa menggunakan wildcard saat mendeklarasikan variabel. Mantan:

1|  List<Dog> dogs = new ArrayList<Dog>();
2|  dogs.add(new Dog("PitBull"));
3|  dogs.add(new Dog("Boxer"));
4|  List<? extends Animal> animals = dogs;
5|  animals.add(new Cat("Sphynx Cat")); // Compilation ERROR !!

Tanpa ? extends, baris ke-4 akan menyebabkan kesalahan kompilasi karena List<Dog> !-> List<Animal>, tetapi baris ke-5 akan berfungsi karena Cat -> Animal. Dengan ? extends baris ke-4 dikompilasi karena sekarang List<Dog> -> List<Animal> tetapi baris ke-5 tidak dapat dikompilasi karena Anda tidak dapat menambahkan Cat ke List<? extends Animal>, karena ini mungkin merupakan daftar beberapa subtipe Hewan lainnya.

Cara lain untuk memahaminya adalah Ketika kita extend, setidaknya ada tipe batas atas, jadi kita tahu tipe apa yang kita keluarkan dari Kotak. Jadi GET diperbolehkan, karena itulah opsi teraman untuk kompiler.

Aturan Umum:
Jika suatu struktur berisi elemen dengan tipe bentuk ? extends E, kita dapat MENDAPATKAN elemen dari struktur, namun kami TIDAK BISA MENEMPATKAN elemen ke dalamnya.

Kontravarian

Demikian pula, untuk menyelesaikan masalah komparator, kita mengubah fungsi printNameOfMostTroubleMakerDog untuk menyertakan wildcard dan menjadikannya ? super Dog. yaitu:

void printNameOfMostTroubleMakerDog(List<Dog> dogs,
                               Comparator<? super Dog> comparator) {
    ...
}
...
// Compiles Cleanly
printNameOfMostTroubleMakerDog(myDogs, troubleMakerComparator);

Sekarang printNameOfMostTroubleMakerDog(myDogs, troubleMakerComparator) pengecekan tipe. ? super Dog berarti semua tipe yang batas bawahnya adalah Anjing, yaitu tipe apa pun yang merupakan supertipe dari Anjing. Jadi Animal & Object adalah tipe yang dapat diterima di sini. Ini juga menjadikan Comparator<Animal> subtipe dari Comparator<Dog> untuk metode ini. Ini adalah Kontravarian. Pasti ada akibat wajar dari prinsip GET-PUT yang kita lihat di atas dan faktanya memang ada. Sekarang kita dapat MENEMPATKAN item ke dalam struktur seperti itu tetapi kita tidak dapat MENDAPATKAN item jika struktur tersebut ada. Kita memerlukan contoh yang berbeda untuk itu.

public static void add_N_DogsTo(List<? super Dog> someList,
                                 int count) {
    if (count == 0)
        return;

    for (int i = 0; i < count; i++)
        // Adding to the list is allowed
        someList.add(new Dog("Stray Dog " + i ));
    // Reading from the list is NOT allowed. Compilation ERROR !!                                     
    Animal d = someList.get(0); 
}

Jika kita memiliki fungsi untuk menambahkan anjing 'N' ke beberapa daftar, yang batas bawahnya adalah Dog, penambahan diperbolehkan tetapi membaca dari daftar tidak diperbolehkan. Hal ini karena, selama PUT kita memiliki tipe konkret yang dapat kita tambahkan, namun selama perintah GET, kita tidak akan pernah mengetahui tipe objek keluaran. Bisa jadi Animal atau Object.

Catatan: Object d = someList.get(0); berfungsi karena di Java, semuanya memperluas Objek. Jadi selalu ada batas atas.

Cara lain untuk memahaminya adalah Ketika kita super, setidaknya ada tipe batas bawah, jadi kita tahu tipe terkecil apa yang bisa kita masukkan ke dalam kotak. Jadi PUT diperbolehkan, karena itu pilihan paling aman untuk kompiler.

Aturan Umum:
Jika suatu struktur berisi elemen dengan tipe bentuk ? super E, kita dapat MENEMPATKAN elemen ke dalam struktur, namun kita BISA JANGAN DAPATKAN elemen darinya.

Fiuh.. Banyak sekali kan?. Itu dulu, tapi itu saja kawan. Sebelum kita berpisah, mari kita jawab saja dua pertanyaan yang tersisa di atas.

1). Apa perbedaan antara List & List<Object>?

  • List telah memilih keluar dari Pemeriksaan Tipe Generik, sedangkan List<Object> telah secara eksplisit memberi tahu kompiler bahwa ia mampu menampung item bertipe Object yang pada dasarnya adalah segalanya di Java. Bagaimana hal itu membantu? Baca poin selanjutnya:
  • List list = new List<String> mungkin tetapi
    List<Object> list = new List<String> TIDAK. Karena,Generik di Java bersifat Invarian,karena alasan yang telah dibahas dalam Prinsip Subtipe & Substitusi.

2). Bagaimana cara kami menjadikan cuplikan kode berikut lebih baik dengan menggunakan Generik ketika keamanan Jenis tidak menjadi perhatian?

// Java
static int numElementsInCommon(Set s1, Set s2) {
    int result = 0;
    for (Object o1 : s1)
        if (s2.contains(o1))
            result++;
    return result;
}

Untuk mengulangi apa yang saya katakan di atas, kita tidak perlu membuat Set menjadi generik di sini (yaitu Set<T>) karena ini hanya membandingkan elemen keduanya. Namun bagaimana jika kita menambahkan wildcard di sini:

// Java
static int numElementsInCommon(Set<?> s1, Set<?> s2) {
    int result = 0;
    for (Object o1 : s1)
        if (s2.contains(o1))
            result++;
    return result;
}

Apa gunanya menambahkan wildcard bagi kita?. Apa perbedaan Set & Set<?>?
Kami sebenarnya telah membuat fungsi ini lebih aman untuk digunakan. Saat menggunakan Set, kita dapat memasukkan elemen apa pun ke dalam fungsi dan mengubah kontennya, tetapi karena Generik bersifat Invarian di Java, Kompiler melarang kita menambahkan apa pun ke dalam Set<?>. Itu tidak berarti bahwa kita tidak bisa memanggil set.removeAll() atau set.pop() di dalam fungsi dan tidak mengubah kontennya. Kami juga bahkan dapat menambahkan null ke koleksi Set<?>. Tapi, keamanan apa pun selalu bermanfaat.

Selesai dan dibersihkan. Semoga bacaan panjang ini membantu Anda memahami bagaimana konsep-konsep ini terjalin dan mengapa penting untuk mempelajari keduanya secara bersamaan.

Terima kasih telah membaca semuanya. Silakan bagikan 🗣 di lingkaran Anda dan tepuk tangan 👏🏻 Jika Anda menikmatinya.

Jika Anda memiliki pertanyaan atau ada sesuatu yang perlu ditambahkan atau diperbaiki dalam artikel ini, jangan ragu untuk menuliskannya (tidak ada kata-kata yang dimaksudkan) di komentar dan saya akan (sekali lagi, tidak ada kata-kata yang dimaksudkan 😛) menghubungi Anda sesegera mungkin .

Ikuti Androidiots untuk konten menakjubkan lainnya.