สวัสดีคนรักทั่วไปและประเภทความปลอดภัย

ฉันอยากเขียนบทความเกี่ยวกับ Generics & Variance มาเป็นเวลานานแล้ว แต่ก็ไม่สามารถเริ่มได้ มีบทความและวิดีโอดีๆ มากมายในหัวข้อนี้ ซึ่งฉันไม่สามารถคิดหาวิธีเพิ่มมูลค่าได้ แต่ที่นี่ฉันกำลังพยายาม หากคุณเป็นผู้เชี่ยวชาญ คุณสามารถตรวจสอบเนื้อหาด้านล่างนี้ได้ หรือหากคุณเป็นมือใหม่ คุณอาจได้รับสิ่งใหม่ๆ บ้าง เริ่มกันเลย.

ข้อมูลทั่วไป พวกเขาคืออะไร?

แนวคิดของ Generics ริเริ่มครั้งแรกในภาษาการเขียนโปรแกรมที่เรียกว่า "ML" (Meta Language) ในปี 1973 แนวทางนี้ทำให้โปรแกรมเมอร์สามารถเขียนฟังก์ชันทั่วไปที่แตกต่างกันเฉพาะกับประเภทที่ฟังก์ชันเหล่านั้นทำงานอยู่เท่านั้น ซึ่งช่วยลดความซ้ำซ้อนของโค้ด เช่น:

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

ฟังก์ชัน swap ในที่นี้อยู่ในประเภท 'a * 'b -> 'b * 'a ไวยากรณ์ที่ดูแปลกตานี้ใช้เพื่ออธิบายประเภททั่วไปใน ML และภาษาอื่นๆ ที่ได้รับแรงบันดาลใจคล้ายกัน 'a เป็นตัวแปรประเภท ซึ่งแสดงถึงประเภทที่เป็นไปได้ อาจเป็น Int, Float, String หรือประเภทเฉพาะใดๆ ที่กำหนดไว้ ณ เวลาที่กรณีการใช้งานหรือการสร้างอินสแตนซ์ ฟังก์ชั่นที่คล้ายกันใน Java & Swift จะเป็น:

// 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)
}

ที่นี่ Pair<A,B> ในตัวอย่าง Java คือ ประเภททั่วไป ซึ่งสามารถเก็บได้สองประเภท A& B เรียกว่า พารามิเตอร์ประเภท ก่อนที่จะมี Generics เราจะต้องเขียน IntPair, StringPair, int_swap, string_swap แยกกันสำหรับประเภทที่ไม่ซ้ำกันทุกประเภท

ประเภทพาราเมตริกที่คลุมเครือเหล่านี้เรียกว่า Generics ใน C#, Java, Rust, Swift, TypeScript และอื่นๆ อีกสองสามรายการ ในภาษา C++ เรียกว่า Parametric Polymorphism

ข้อมูลทั่วไป แนะนำแนวคิดของพารามิเตอร์ประเภท ซึ่งทำให้สามารถออกแบบโครงสร้าง (คลาส อินเทอร์เฟซ) และฟังก์ชัน (เมธอด) ที่เลื่อนข้อกำหนดของหนึ่งประเภทขึ้นไปจนกว่าจะถึงเวลาของการสร้างอินสแตนซ์ด้วยโค้ดไคลเอนต์

ข้อมูลทั่วไป — พวกเขาทำงานอย่างไรภายใต้ประทุน?

ต่อไปนี้เป็นข้อดีบางประการของการเขียนโค้ดทั่วไป:

  1. การตรวจสอบประเภทที่แข็งแกร่งยิ่งขึ้นในเวลาคอมไพล์
  2. ช่วยให้เราสามารถเขียนโค้ดที่ใช้ได้กับหลายประเภทโดยมีพฤติกรรมพื้นฐานเหมือนกัน เช่น: List<T>
  3. รหัสที่ปรับให้เหมาะสม

ผู้อ่านที่ฉลาดจะตั้งคำถามถึงจุดที่ 3 ในรายการด้านบนทันที เพิ่มประสิทธิภาพหรือไม่? ในสิ่งที่รู้สึก? จุดนี้ต้องมีการแก้ไข ควรระบุเป็น Optimized ByteCode แต่ก่อนอื่นเรามาจัดการกับสองประเด็นแรกกันก่อน

พิจารณาตัวอย่างโค้ดทั้งสองด้านล่างนี้ (ใน 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

ตัวอย่างเหล่านี้สร้างรหัสไบต์ที่เหมือนกันทุกประการ ในความเป็นจริง Java Compiler แปลงตัวอย่างที่ 1 โดยใช้ Generics เป็นตัวอย่างที่ 2 คอมไพเลอร์ทำให้แน่ใจว่า List<String> เก็บเฉพาะรายการ String ณ เวลาคอมไพล์ ดังนั้นเราจึงไม่ประสบปัญหาขณะรันไทม์ เนื่องจากคอมไพเลอร์ดูแลการแคสต์อัตโนมัติ จึงป้องกัน ClassCastException ใดๆ ที่อาจถูกส่งไปที่รันไทม์ ดังนั้นความปลอดภัยประเภทที่ดีกว่า นอกจากนี้ยังมีเรื่องของ List<T> อ่านเป็น List of T สำหรับวิธีการเช่น size() บน List สิ่งที่เราต้องมีคือการนับจำนวนองค์ประกอบ ไม่ใช่ประเภทพื้นฐานขององค์ประกอบเหล่านั้น ดังนั้นนามธรรมที่ดีกว่า ปราศจากข้อมูลเฉพาะประเภท เคล็ดลับนี้ใช้โดย Java Compiler เรียกว่า Type Erasure

โมโนมอร์ฟิเซชัน

มีอีกวิธีหนึ่งที่คอมไพเลอร์จะทำงานกับโค้ดทั่วไปได้ สามารถคอมไพล์ได้เพื่อสร้างฟังก์ชันทั่วไปเวอร์ชันพิเศษแยกกัน สับสนเหรอ? มาดูตัวอย่างง่ายๆ ใน 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

คอมไพเลอร์ Java จะมีฟังก์ชัน print_hash เพียงชุดเดียว แต่จริงๆ แล้วคอมไพเลอร์ Rust จะสร้างสำเนาของฟังก์ชันนี้สองชุดสำหรับอาร์กิวเมนต์ที่เป็นรูปธรรมแต่ละประเภท ข้อดีคืออะไร ข้อดีคือคอมไพเลอร์สามารถปรับโค้ดให้เหมาะสมตามประเภทคอนกรีตในภาพ ซึ่งหมายความว่าการโทรใดๆ ไปยัง print_hash จะถูกต่อสาย ณ จุดที่โทร การเรียกการจัดส่งแบบคงที่โดยตรง ไปยังการใช้งานที่เกี่ยวข้อง ซึ่งจะช่วยประหยัดค่าทางอ้อมในการเรียกใช้ฟังก์ชัน สิ่งนี้เรียกว่า การทำให้เป็นโมโนมอร์ฟิก — การแปลงโค้ดโพลีมอร์ฟิกเป็นโค้ดโมโนมอร์ฟิก

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

หากประโยชน์ดูเหมือนน้อย ให้พิจารณาประเภท: Array<Bool> & Array<Int> คอมไพเลอร์สร้างสำเนาพิเศษ: Array_Bool & Array_Int สำหรับ Bool & Int ตามลำดับ คอมไพเลอร์สามารถจัดวางหน่วยความจำตามขนาดของ Bool & Int ไว้ในบรรทัด (เมื่อเป็นไปได้) และประหยัดพื้นที่ แต่ละเมธอดบน Array จะสร้างการบันทึกโค้ดพิเศษทางอ้อมไปยังการเรียกเมธอดเช่นเดียวกัน ดังนั้นโค้ดทั่วไปจึงรวดเร็วและมีประสิทธิภาพหน่วยความจำเหมือนกับว่าคุณเขียนโค้ดพิเศษด้วยลายมือโดยตรง การเปรียบเทียบเป็นอย่างไรบ้าง: ArrayList<Boolean> ใน Java ขึ้นอยู่กับการใช้งาน และใช้หน่วยความจำมากกว่าใน Rust อย่างน้อย 16 เท่า

ดูเหมือนจะน่าทึ่งใช่ไหม มีข้อเสียเช่นกัน: Code Bloat&เวลาการคอมไพล์ช้า คอมไพเลอร์สร้างอินสแตนซ์สำเนาอิสระของโค้ดทั่วไปสำหรับแต่ละชุดที่แตกต่างกันของพารามิเตอร์ทั่วไปที่ถูกเรียกใช้ นำไปสู่ขนาดไบนารีขนาดใหญ่และคอมไพล์ฟังก์ชันเดียวกันซึ่งอาจหลายครั้ง

ความปลอดภัยประเภททั่วไป

หากคุณมาถึงจุดนี้ คุณก็รู้แล้วว่าประเภทความปลอดภัยคืออะไร (เวลาคอมไพล์) การตรวจจับข้อผิดพลาดก่อนที่โปรแกรมจะรันเป็นสิ่งที่ดีเสมอไป ข้อมูลทั่วไปช่วยให้เราเขียนรหัสเซฟประเภทที่นำมาใช้ซ้ำได้ ลองพิจารณาตัวอย่างจากหนังสือ "Effective Java":

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

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

มีสองวิธีที่เราสามารถสร้างคอลเลกชันแสตมป์ได้

ใน 1 เรามีคอลเลกชันดิบซึ่งหมายความว่าสามารถเก็บอะไรก็ได้ มีความเป็นไปได้ที่เราอาจเพิ่มอินสแตนซ์ของคลาส Coin ลงไป มันจะคอมไพล์ได้สำเร็จและจะล้มเหลวในขณะรันไทม์เมื่อเราดึงข้อมูล stamp จากคอลเล็กชัน

ใน 2 เรามีคอลเลกชัน Generic โดยที่พารามิเตอร์ทั่วไปถูกกำหนดให้เป็น Stamp นี่เป็นสิ่งที่ดี นี่เป็นประเภทที่ปลอดภัย ไม่มีทางที่เราจะเพิ่มอินสแตนซ์ของ Coin ให้กับคอลเลกชันแสตมป์ได้ คอมไพเลอร์เข้ามาช่วยเหลือเราและไม่ยอมให้โค้ดนี้คอมไพล์

ลองนึกภาพใครบางคนใส่อินสแตนซ์ java.util.Date ลงในคอลเลกชันที่ควรมีเพียง java.sql.Date อินสแตนซ์ ประเภทที่มีพารามิเตอร์จะป้องกันสิ่งนี้ในเวลารวบรวม

หากคุณใช้ประเภท Raw คุณจะสูญเสียประโยชน์ด้านความปลอดภัยและความชัดเจนของ Generics ทั้งหมด

___________________________________________________________________

คำถาม:
อะไรคือความแตกต่างระหว่าง List และ List<Object>?

(ไม่ต้องกังวล เราจะตอบคำถามนี้ในไม่ช้า)

___________________________________________________________________

เดี๋ยวก่อน เราต้องการความปลอดภัยประเภทเสมอไปหรือเปล่า?

พิจารณาข้อมูลโค้ดนี้ (อีกครั้งจากหนังสือ Java ที่มีประสิทธิภาพ):

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

เมธอดนี้ numElementsinCommon คำนวณจำนวนองค์ประกอบทั่วไประหว่างสองชุด ไม่จำเป็นต้องทำให้ ชุด อินพุตเหล่านี้เป็นแบบทั่วไป เนื่องจากเราเพียงเข้าถึงและเปรียบเทียบองค์ประกอบระหว่างกันเท่านั้น ดูเหมือนว่าเราไม่ต้องการความปลอดภัยเสมอไป

แต่เราสามารถทำให้ดีขึ้นด้วย Generics ได้หรือไม่ ถ้าใช่ ดีกว่าอย่างไร
คำตอบคือใช่ เราทำได้โดยใช้ Wildcard(?) นี่ไม่ใช่เครื่องหมายคำถามไม่ใช่ ใน Java เรียกว่า Wildcard

อดทนไว้ อีกไม่นานเราจะได้เป็นไวด์การ์ด ก่อนอื่นเราต้องเข้าใจหลักการบางประการก่อน

การพิมพ์ย่อยและการทดแทนหลักการ (SP)

การพิมพ์ย่อย จะต้องดูเหมือนเป็นดินแดนที่คุ้นเคยหรือรู้จัก และจริงๆ แล้วเป็นเช่นนั้น มันเป็นคุณสมบัติหลักของภาษาเชิงวัตถุเช่น Java และเราใช้บ่อยเกินไป เมื่อเรา extend หรือ implement ประเภทใดประเภทหนึ่ง ประเภทที่เฉพาะเจาะจงมากขึ้น (เช่น ประเภทย่อย/ประเภทย่อย) สามารถใส่ในคอนเทนเนอร์ที่ใหญ่กว่าได้ (เช่น ประเภทหลัก/ประเภทพิเศษ) ตัวอย่างเช่น:

  • จำนวนเต็มเป็นประเภทย่อยของตัวเลข
  • Double เป็นประเภทย่อยของ Number
  • String เป็นประเภทย่อยของ Object
  • String[] เป็นประเภทย่อยของ Object[]
  • ArrayList เป็นประเภทย่อยของ List

ต่อไปนี้เราจะแสดง A -> B เป็น A is a subtype of B & A !-> B เป็น A is NOT a subtype of B

และ หลักการทดแทน พูดว่า:

หาก S -> T หมายความว่าคำใดๆ ประเภท S สามารถใช้ได้อย่างปลอดภัยในบริบทที่ คาดว่าจะมีการใช้คำประเภท T

ตอนนี้คุณจำชื่อบทความนี้ได้แล้ว ฉันรู้ว่านี่เป็นเวลานาน แต่ถึงเวลาเริ่มต้นการเดินทางของเราไปยังส่วนที่น่าสนใจ —ความแปรปรวน ลองพิจารณาลำดับชั้นของชั้นเรียนด้านล่างสำหรับการสนทนาทั้งหมดที่รออยู่ข้างหน้า ตัวอย่างทั้งหมดจะอยู่ในภาษา 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);
    }
}

มีคลาส Animal Dog & Cat ขยาย Animal เนื่องจากสุนัข เป็น สัตว์ แมว เป็น สัตว์ Animal แต่ละตัวมี name และ troubleMakerRating ดังนั้นตามหลักการทดแทน (SP) หากเรามีคอลเลกชั่นสัตว์ ก็อนุญาตให้เพิ่มตัวอย่างสุนัขหรือแมวเข้าไปได้:

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

การพิมพ์ย่อยที่นี่มีสองรูปแบบ:
1. อนุญาตให้เพิ่มลงในรายการได้เนื่องจาก Dog/Cat is a subtype of Animal.
2. อนุญาตให้ดำเนินการมอบหมายได้เนื่องจาก ArrayList is a subtype of List

ดังนั้น ถ้า Dog -> Animal , ArrayList -> List มันดูสมเหตุสมผลมากที่ ArrayList<Dog> -> List<Animal> มาเขียนสิ่งนี้ลงในโค้ด:

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

ลองรวบรวมโค้ดด้านบน มันจะไม่. คอมไพเลอร์พ่น Incompatible Types คอมไพเลอร์ไม่อนุญาตให้เราแทนที่ List<Animal> ด้วย ArrayList<Dog> เพราะมันไม่ปลอดภัย

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

หากไม่มีข้อผิดพลาดในการคอมไพล์ที่บรรทัดแรก ภายใต้หลักการทดแทน จะอนุญาตให้เพิ่มอินสแตนซ์ Cat ลงในรายชื่อสัตว์ซึ่งจริงๆ แล้วเป็นรายการ Dog และ Cats and Dogs ไม่เข้ากัน ปัญหาจะเกิดขึ้นในเวลาดำเนินการเมื่อเราแยกวิเคราะห์รายชื่อสัตว์ที่คิดว่าพวกมันทั้งหมดเป็นสุนัขแต่เป็นแมวปรากฏขึ้น จำกฎ: ข้อผิดพลาดในการคอมไพล์คือดี ข้อผิดพลาดรันไทม์ไม่ดี นั่นเป็นสาเหตุที่หลักการทดแทนใช้ไม่ได้ที่นี่ เช่น ArrayList<Dog> !-> List<Animal>

แต่ก็มีบางกรณีที่เราต้องการให้ Substitution Principle ทำงาน เช่น เราต้องการให้ Compiler ผ่อนคลายสักหน่อยเพราะเรารู้ว่าเรากำลังทำอะไรอยู่

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

ฟังก์ชัน printNamesรับรายชื่อสัตว์และพิมพ์ชื่อสัตว์เหล่านั้น ดังนั้นเราจึงควรใช้ฟังก์ชันนี้ซ้ำเพื่อพิมพ์ชื่อของรายการสุนัข ท้ายที่สุดแล้ว ฟังก์ชัน printNames เข้าถึงเฉพาะสัตว์จากรายการเท่านั้น และไม่ได้พยายามเขียนลงไป แต่งานของคอมไพเลอร์คือต้องปลอดภัย ดังนั้นจึงเกิดข้อผิดพลาดใน printNames(myDogs) โอเวอร์คอมไพเลอร์อัจฉริยะ!!

และจะเป็นอย่างไรถ้าเราจำเป็นต้องเปรียบเทียบสุนัขสองตัวโดยใช้เครื่องเปรียบเทียบสัตว์ทั่วไป:

// 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. printNameOfMostTroubleMakerDogรับรายชื่อสุนัขและพิมพ์ชื่อของสุนัขที่สร้างปัญหามากที่สุดโดยอิงจากตัวเปรียบเทียบอินพุต
  2. เราสร้าง troubleMakerComparator ที่นำมาใช้ซ้ำได้ ซึ่งเปรียบเทียบคะแนนผู้สร้างปัญหาของสัตว์แต่ละตัว
  3. ไม่มีการคอมไพล์ เนื่องจาก printNameOfMostTroubleMakerDogคาดว่าจะมี Comparator<Dog> แต่เรากำลังผ่านใน Comparator<Animal>

คอมไพเลอร์ควรอนุญาตสิ่งนี้ หาก Comparator<Animal> สามารถเปรียบเทียบสัตว์สองตัวได้ ก็สามารถเปรียบเทียบสุนัขสองตัวได้อย่างแน่นอน

ตอนนี้คำถามก็เกิดขึ้น หากเรารู้ว่าการแทนที่บางอย่างข้างต้นนั้นปลอดภัย เราไม่ควรถ่ายทอดสิ่งนี้ให้คอมไพเลอร์ด้วยหรือ หากคำตอบของทุกคำถามคือ ไม่ ก็ไม่จำเป็นต้องมีบทความนี้ 😛
จำ ไวลด์การ์ด ( ?) ใช่ เราจะใช้มันตอนนี้

ความแปรปรวน

ความแปรปรวนหมายถึงการพิมพ์ย่อยระหว่างประเภทที่ซับซ้อนมากขึ้นเกี่ยวข้องกับการพิมพ์ย่อยระหว่างส่วนประกอบอย่างไร ตัวอย่างเช่น รายการ Cats ควรเกี่ยวข้องกับรายการ Animals อย่างไร หรือฟังก์ชันที่ส่งคืน Cat ควรเกี่ยวข้องกับฟังก์ชันที่ส่งคืน Animal อย่างไร

มีความแปรปรวนที่เป็นไปได้สี่แบบ:

  • ความแปรปรวนร่วม: รักษาลำดับของความสัมพันธ์ของการพิมพ์ย่อย เช่น
    ถ้า A -> B ดังนั้น Box<A> -> Box<B>
  • ความขัดแย้ง: กลับลำดับของความสัมพันธ์ของการพิมพ์ย่อย เช่น
    ถ้า A -> B ดังนั้น Box<B> -> Box<A>
  • ความแปรผัน: หากเป็นไปตามทั้งสองข้อข้างต้น เช่น
    ถ้า A -> B ดังนั้น Box<A> -> Box<B> & Box<B> -> Box<A>
  • ค่าคงที่: ไม่ใช้ ความแปรปรวนร่วม หรือ ความแปรปรวนร่วม

เพื่อแก้ปัญหาการทดแทนที่เราเผชิญเมื่อใช้ printNames & printNameOfMostTroubleMakerDog เราจะใช้ Covariance & Contravariance ตามลำดับ

ความแปรปรวนร่วม

หาก ArrayList<Dog> กลายเป็นประเภทย่อยของ List<Animal> ฟังก์ชัน printNames จะสามารถนำมาใช้ซ้ำได้สำหรับรายการใดๆ ที่มีประเภทที่ขยาย Animal นี่เป็นวิธีที่พวกเราทำ:

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

printNames(myDogs); // Compiles cleanly

ตอนนี้ printNames(myDogs); ตรวจสอบประเภทและคอมไพล์ สังเกตการเปลี่ยนแปลงลายเซ็น printNames ไม่ยอมรับ Collection<Animal> อีกต่อไป แต่เป็น Collection<? extends Animal> เมื่อเราใช้ ? extends โดยพื้นฐานแล้วเรากำลังบอกคอมไพเลอร์ว่า คอลเลกชันของรายการที่มีขอบเขตบนเป็น Animal ได้รับการยอมรับโดยฟังก์ชัน printNames

สิ่งนี้ทำสองสิ่ง:

  1. มันทำให้ List<Dog> -> Collection<Animal> สิ่งนี้เรียกว่า ความแปรปรวนร่วม
  2. และภายใน printNames เราสามารถ ไม่ เพิ่มรายการใด ๆ ลงในคอลเลกชัน แต่เราสามารถแยกวิเคราะห์หรือเข้าถึงรายการออกจากรายการได้ เราไม่สามารถเพิ่มได้เนื่องจากเป็นไปได้ว่ารายการนี้อาจเป็นรายชื่อสุนัข (รายชื่อที่เป็นเนื้อเดียวกัน) หรือรายชื่อสัตว์บางชนิด (รายชื่อที่ต่างกัน) เนื่องจากเราไม่แน่ใจว่ารายการสำคัญคืออะไร คอมไพลเลอร์จึงไม่อนุญาตให้เพิ่มคอลเลกชันใด ๆ

เรายังสามารถใช้ wildcard เมื่อประกาศตัวแปรได้ด้วย อดีต:

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 !!

หากไม่มี ? extends บรรทัดที่ 4 จะทำให้เกิดข้อผิดพลาดในการรวบรวมเนื่องจาก List<Dog> !-> List<Animal> แต่บรรทัดที่ 5 จะใช้งานได้เนื่องจาก Cat -> Animal ด้วย ? extends การคอมไพล์บรรทัดที่ 4 เนื่องจากตอนนี้ List<Dog> -> List<Animal> แต่อันดับที่ 5 ไม่ได้คอมไพล์ เนื่องจากคุณไม่สามารถเพิ่ม Cat ลงใน List<? extends Animal> ได้ เนื่องจากอาจเป็นรายการประเภทย่อยอื่นๆ ของ Animal

วิธีทำความเข้าใจอีกวิธีหนึ่งคือ เมื่อเรา extend มีประเภทขอบเขตบนเป็นอย่างน้อย ดังนั้นเราจึงรู้ว่าเรากำลังนำประเภทใดออกจากกล่อง ดังนั้น GET จึงได้รับอนุญาต เพราะนั่นเป็นตัวเลือกที่ปลอดภัยที่สุดสำหรับคอมไพเลอร์

กฎทั่วไป:
หากโครงสร้างมีองค์ประกอบที่มีประเภทรูปแบบ ? extends E เราสามารถ GET องค์ประกอบออกจาก โครงสร้าง แต่ เราไม่สามารถใส่ องค์ประกอบลงไปได้

ความขัดแย้ง

ในทำนองเดียวกัน เพื่อแก้ปัญหาตัวเปรียบเทียบ เราเปลี่ยนฟังก์ชัน printNameOfMostTroubleMakerDog เพื่อรวมไวด์การ์ดและทำให้เป็น ? super Dog เช่น:

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

ตอนนี้ printNameOfMostTroubleMakerDog(myDogs, troubleMakerComparator) การตรวจสอบประเภท ? super Dog หมายถึง ชนิดใดๆ ที่มีขอบเขตล่างเป็น Dog เช่น ชนิดใดๆ ที่เป็น supertype ของ Dog ดังนั้น Animal & Object จึงเป็นประเภทที่ยอมรับได้ที่นี่ นอกจากนี้ยังทำให้ Comparator<Animal> เป็นประเภทย่อยของ Comparator<Dog> สำหรับเมธอดนี้ นี่คือ ความขัดแย้ง จะต้องมีข้อพิสูจน์บางประการจากหลักการ GET-PUT ที่เราเห็นข้างต้น และที่จริงแล้วก็มีอยู่ ขณะนี้เราสามารถ วาง รายการภายในโครงสร้างดังกล่าวได้ แต่เราไม่สามารถ GET รายการออกได้ หากเป็นเช่นนั้น เราจะต้องมีตัวอย่างที่แตกต่างออกไป

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); 
}

หากเรามีฟังก์ชันในการเพิ่มสุนัข 'N' ลงในบางรายการซึ่งมีขอบเขตล่างคือ Dog ก็อนุญาตให้เพิ่มได้ แต่ไม่อนุญาตให้อ่านจากรายการ นี่เป็นเพราะว่าในระหว่าง PUT เรามีประเภทที่เป็นรูปธรรมที่เราสามารถเพิ่มได้ แต่ในระหว่างคำสั่ง GET เราไม่สามารถทราบประเภทของออบเจ็กต์เอาต์พุตได้ อาจเป็น Animal หรือ Object

หมายเหตุ: Object d = someList.get(0); ใช้งานได้เพราะใน Java ทุกอย่างขยาย Object จึงมีขอบเขตบนเสมอ

วิธีทำความเข้าใจอีกวิธีหนึ่งคือ เมื่อเรา super มีประเภทขอบเขตล่างเป็นอย่างน้อย ดังนั้นเราจึงรู้ว่าประเภทที่เล็กที่สุดที่เราสามารถใส่ลงในช่องได้ ดังนั้น PUT จึงได้รับอนุญาต เพราะนั่นเป็นตัวเลือกที่ปลอดภัยที่สุดสำหรับคอมไพเลอร์

กฎทั่วไป:
หากโครงสร้างมีองค์ประกอบที่มีรูปแบบ ? super E เรา สามารถใส่ องค์ประกอบลงในโครงสร้างได้ แต่ เราสามารถทำได้ ไม่ได้รับ องค์ประกอบออกจากมัน

วุ้ย.. นั่นมันมากใช่มั้ย. มันเป็น แต่นั่นมันคน ก่อนที่เราจะแยกทางกัน เรามาตอบคำถามสองข้อที่เราทิ้งไว้ด้านบนกันดีกว่า

1) อะไรคือความแตกต่างระหว่าง List และ List<Object>?

  • A List ได้ยกเลิกการใช้การตรวจสอบประเภททั่วไป โดยที่ List<Object> ได้บอกคอมไพเลอร์อย่างชัดเจนว่าสามารถเก็บรายการประเภท Object ซึ่งโดยพื้นฐานแล้วคือทุกอย่างใน Java มันช่วยได้อย่างไร? อ่านประเด็นถัดไป:
  • List list = new List<String> เป็นไปได้ แต่
    List<Object> list = new List<String> ไม่ เนื่องจาก ข้อมูลทั่วไปใน Java เป็นค่าคงที่ด้วยเหตุผลที่ได้กล่าวไว้แล้วในหลักการพิมพ์ย่อยและการทดแทน

<แข็งแกร่ง>2) เราจะทำให้ข้อมูลโค้ดต่อไปนี้ดีขึ้นได้อย่างไรโดยใช้ Generics โดยที่ไม่กังวลเรื่องความปลอดภัยของประเภท

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

หากต้องการทำซ้ำสิ่งที่ฉันกล่าวไว้ข้างต้น เราไม่จำเป็นต้องทำให้ชุดเป็นแบบทั่วไปที่นี่ (เช่น Set<T>) เพราะเป็นเพียงการเปรียบเทียบองค์ประกอบของทั้งสองอย่างเท่านั้น แต่ถ้าเราเพิ่มไวด์การ์ดที่นี่:

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

การเพิ่ม wildcard ซื้ออะไรให้เราบ้าง?. Set และ Set<?> แตกต่างกันอย่างไร
เราได้ทำให้ฟังก์ชันนี้ปลอดภัยยิ่งขึ้นในการใช้งานมาก เมื่อใช้ Set เราสามารถใส่องค์ประกอบใดก็ได้ภายในฟังก์ชันและแก้ไขเนื้อหาได้ แต่เนื่องจาก Generics เป็นค่าคงที่ใน Java Compiler จึงห้ามไม่ให้เราเพิ่มสิ่งใดใน Set<?> ไม่ได้หมายความว่าเราไม่สามารถเรียก set.removeAll() หรือ set.pop() ภายในฟังก์ชันและไม่สามารถแก้ไขเนื้อหาได้ นอกจากนี้เรายังสามารถเพิ่ม null ลงในคอลเลกชัน Set<?> ได้ด้วย แต่ความปลอดภัยใดๆ ก็มีประโยชน์เสมอ

เสร็จแล้วก็ปัดฝุ่น หวังว่าการอ่านมาเป็นเวลานานนี้จะช่วยให้คุณเข้าใจว่าแนวคิดเหล่านี้เชื่อมโยงกันอย่างไร และเหตุใดการเรียนรู้ทั้งสองอย่างร่วมกันจึงเป็นเรื่องสำคัญ

ขอบคุณที่อ่านจนจบ กรุณาแบ่งปัน 🗣 ในแวดวงของคุณและปรบมือ 👏🏻 หากคุณชอบมัน

หากคุณมีข้อสงสัยหรือมีอะไรเพิ่มเติมหรือแก้ไขในบทความนี้ อย่าลังเลที่จะแสดงความคิดเห็น (ไม่มีเจตนาเล่นสำนวน) แล้วฉันจะกลับมา (อีกครั้ง ไม่มีเจตนาเล่นสำนวน 😛) กลับมาหาคุณโดยเร็วที่สุด .

ติดตาม "Androidiots" เพื่อดูเนื้อหาที่น่าทึ่งอีกมากมาย