Chào mừng các bạn đến với Cafedev, nơi chúng ta cùng nhau tìm hiểu về thế giới đa dạng và phong phú của ngôn ngữ lập trình Kotlin. Trong hành trình này, chúng ta sẽ khám phá sâu hơn về generics trong Kotlin, đi sâu vào các khái niệm như `in`, `out`, và `where`. Cùng với đó, chúng ta sẽ hiểu rõ hơn về biến thể tại thời điểm khai báo và chiều chi trả của kiểu. Hãy cùng nhau học hỏi và chia sẻ kiến thức với Kotlin trên Cafedev!

Trong Kotlin, các lớp có thể có các tham số kiểu, tương tự như trong Java:

class Box(t: T) {
var value = t
}

Để tạo một thể hiện của lớp như vậy, chỉ cần cung cấp các đối số kiểu:

val box: Box = Box(1)

Nhưng nếu các tham số có thể được suy luận, ví dụ như từ các đối số constructor, bạn có thể bỏ qua các đối số kiểu:

val box = Box(1) // 1 has type Int, so the compiler figures out that it is Box

1. Sự biến đổi

Một trong những khía cạnh khó nhằn nhất của hệ thống kiểu Java là các loại wildcards (xem Java Generics FAQ). Kotlin không có chúng. Thay vào đó, Kotlin có biến đổi tại điểm khai báo và chiếu loại.
Hãy nghĩ về lý do tại sao Java cần những dấu chấm câu kỳ bí này. Vấn đề được giải thích rõ trong Effective Java, 3rd Edition, Mục 31: Sử dụng bounded wildcards để tăng cường tính linh hoạt của API. Trước hết, các loại generic trong Java là invariant, có nghĩa là List không phải là một loại con của List. Nếu List không phải là invariant, nó sẽ không tốt hơn mảng trong Java, vì đoạn mã sau đây sẽ biên dịch nhưng gây ra một ngoại lệ khi chạy:

// Java
List<String> strs = new ArrayList<String>();
List<Object> objs = strs; // !!! A compile-time error here saves us from a runtime exception later.
objs.add(1); // Put an Integer into a list of Strings
String s = strs.get(0); // !!! ClassCastException: Cannot cast Integer to String


Java ngăn chặn những điều như vậy để đảm bảo an toàn khi chạy. Nhưng điều này có ảnh hưởng. Ví dụ, hãy xem xét phương thức addAll() từ giao diện Collection. Theo cách hiểu tự nhiên, bạn sẽ viết như sau:

// Java
interface Collection<E> ... {
    void addAll(Collection<E> items);
}


Nhưng sau đó, bạn sẽ không thể thực hiện điều sau (một cách hoàn toàn an toàn):

// Java
void copyAll(Collection<Object> to, Collection<String> from) {
    to.addAll(from);
    // !!! Would not compile with the naive declaration of addAll:
    // Collection<String> is not a subtype of Collection<Object>
}


(Trong Java, bạn có thể đã học điều này một cách khó khăn, xem Effective Java, 3rd Edition, Mục 28: Ưu tiên danh sách hơn mảng)
Đó là lý do tại sao chữ ký thực sự của addAll() là như sau:

// Java
interface Collection<E> ... {
    void addAll(Collection<? extends E> items);
}


Đối số kiểu wildcards ? extends E cho biết rằng phương thức này chấp nhận một bộ sưu tập các đối tượng thuộc về Ehoặc một loại con của E, không chỉ là E chính nó. Điều này có nghĩa là bạn có thể an toàn đọc E từ các mục (các phần tử của bộ sưu tập này là các thể hiện của một lớp con của E), nhưng không thể ghi vào nó vì bạn không biết đối tượng nào tuân thủ loại con không biết của E. Đổi lại cho giới hạn này, bạn có được hành vi mong muốn: Collection một loại con của Collection<? extends Object>. Nói cách khác, wildcard với định giới hạn extends (định giới hạn trên) làm cho kiểu covariant.
Chìa khóa để hiểu tại sao điều này hoạt động khá đơn giản: nếu bạn chỉ có thể lấy các mục từ một bộ sưu tập, thì việc sử dụng một bộ sưu tập của các chuỗi và đọc Objects từ đó là đúng. Ngược lại, nếu bạn chỉ có thể đặt các mục vào bộ sưu tập, việc lấy một bộ sưu tập của các Objects và đặt Strings vào đó cũng là đúng: trong Java có List<? super String>, chấp nhận Strings hoặc bất kỳ loại cha nào của nó.
Cái sau được gọi là contravariance, và bạn chỉ có thể gọi các phương thức có tham số là String trên List<? super String>
(ví dụ, bạn có thể gọi add(String) hoặc set(int, String)). Nếu bạn gọi điều gì đó trả về T trong List,
bạn không nhận được một String, mà là một Object.
Joshua Bloch đặt tên Producers cho các đối tượng bạn chỉ đọc từConsumers cho những đối tượng bạn chỉ ghi vào. Anh ấy khuyến nghị:

“Đối với sự linh hoạt tối đa, hãy sử dụng các loại wildcards trên các tham số đầu vào đại diện cho những người tạo hoặc người tiêu thụ”, và đề xuất việc nhớ sau đây: PECS đại diện cho Producer-Extends, Consumer-Super.

Nếu bạn sử dụng một đối tượng người tạo, ví dụ như List<? extends Foo>, bạn không được phép gọi add() hoặc set() trên đối tượng này, nhưng điều này không có nghĩa là nó là immutable: ví dụ, không có gì ngăn bạn khỏi việc gọi clear() để xóa tất cả các mục khỏi danh sách, vì clear() không có tham số nào cả. Điều duy nhất được đảm bảo bởi wildcards (hoặc các loại biến đổi khác) là an toàn kiểu. Sự không thay đổi là một câu chuyện hoàn toàn khác.

2. Sự biến đổi tại điểm khai báo

Hãy giả sử có một giao diện chung Source không có bất kỳ phương thức nào có tham số là T, chỉ có các phương thức trả về T:

// Java
interface Source<T> {
    T nextT();
}

Sau đó, việc lưu trữ một tham chiếu đến một thể hiện của Source trong một biến có kiểu là Source sẽ là an toàn hoàn toàn – không có phương thức người nào để gọi. Nhưng Java không biết điều này và vẫn cấm:

// Java
void demo(Source<String> strs) {
    Source<Object> objects = strs; // !!! Not allowed in Java
    // ...
}


Để sửa lỗi này, bạn nên khai báo đối tượng có kiểu Source<? extends Object>. Làm như vậy là vô nghĩa,
vì bạn có thể gọi tất cả các phương thức giống nhau trên một biến như trước đó, vì vậy không có giá trị thêm vào từ kiểu phức tạp hơn. Nhưng trình biên dịch không biết điều đó.
Trong Kotlin, có một cách để giải thích điều này cho trình biên dịch. Điều này được gọi là biến đổi tại điểm khai báo: bạn có thể đánh dấu tham số kiểu T của Source để đảm bảo rằng nó chỉ được trả về (tạo ra) từ các thành viên của Source, và không bao giờ được dùng. Để làm điều này, sử dụng từ khóa out:

interface Source<out T> {
    fun nextT(): T
}

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // This is OK, since T is an out-parameter
    // ...
}

Quy tắc chung là: khi một tham số kiểu T của một lớp C được khai báo là out, nó chỉ có thể xuất hiện ở vị trí out trong các thành viên của C, nhưng trong trả lại C có thể an toàn là một siêu kiểu của C.
Nói cách khác, bạn có thể nói rằng lớp Ccovariant đối với tham số T, hoặc T là một covariant tham số kiểu. Bạn có thể tưởng tượng C như một người tạo của T, và KHÔNG phải là người dùng của T.
Modifier out được gọi là một variance annotation, và vì nó được cung cấp tại điểm khai báo tham số kiểu, nó cung cấp declaration-site variance. Điều này đối lập với use-site variance của Java, nơi wildcards trong việc sử dụng kiểu làm cho các kiểu là covariant.
Ngoài out, Kotlin cung cấp một annotation variance bổ sung: in. Nó làm cho một tham số kiểu contravariant, có nghĩa là nó chỉ có thể được tiêu thụ và không bao giờ được sản xuất. Một ví dụ tốt về kiểu contravariant là Comparable:

interface Source<out T> {
    fun nextT(): T
}

fun demo(strs: Source<String>) {
    val objects: Source<Any> = strs // This is OK, since T is an out-parameter
    // ...
}

Các từ inout có vẻ là dễ hiểu (vì chúng đã được sử dụng thành công trong C# trong một khoảng thời gian khá dài),và vì vậy, cái nhớ được đề cập ở trên thực sự không cần thiết. Trong thực tế, nó có thể được diễn đạt lại ở một cấp độ trừu tượng cao hơn:
The Existential Transformation: Consumer in, Producer out! 🙂

3. Type projections

3.1 Use-site variance: type projections

Việc khai báo một tham số kiểu Tout và tránh rắc rối với subtyping ở điểm sử dụng rất dễ dàng, nhưng một số lớp không thể thực sự bị hạn chế chỉ trả về T! Một ví dụ tốt về điều này là Array:

class Array<T>(val size: Int) {
    operator fun get(index: Int): T { ... }
    operator fun set(index: Int, value: T) { ... }
}

Lớp này không thể là covariant hoặc contravariant đối với T. Điều này đặt ra một số sự cứng nhắc. Xem xét hàm sau:

fun copy(from: Array<Any>, to: Array<Any>) {
    assert(from.size == to.size)
    for (i in from.indices)
        to[i] = from[i]
}

Hàm này dự kiến sẽ sao chép các mục từ một mảng sang mảng khác. Hãy thử áp dụng nó vào thực tế:

val ints: Array<Int> = arrayOf(1, 2, 3)
val any = Array<Any>(3) { "" }
copy(ints, any)
//   ^ type is Array<Int> but Array<Any> was expected

Ở đây, bạn gặp phải vấn đề quen thuộc: Arrayinvariant đối với T, và vì vậy cả ArrayArray đều không phải là một loại con của loại khác. Tại sao không? Một lần nữa, điều này là do copy có thể có một hành vi không mong đợi, ví dụ, nó có thể cố gắng viết một String vào from, và nếu bạn thực sự chuyển một mảng Int vào đó, một ClassCastException sẽ được ném sau đó.
Để ngăn chặn hàm copy viết vào from, bạn có thể làm như sau:

fun copy(from: Array, to: Array) { … }

Đây là type projection, có nghĩa là from không phải là một mảng đơn giản, mà thay vào đó là một mảng bị hạn chế (projected). Bạn chỉ có thể gọi các phương thức trả về tham số kiểu T, điều này có nghĩa là bạn chỉ có thể gọi get(). Đây là cách tiếp cận của chúng tôi đối với use-site variance, và tương ứng với Array<? extends Object> của Java mà đơn giản hơn một chút.
Bạn cũng có thể áp dụng in cho một kiểu:

fun fill(dest: Array, value: String) { … }

Array tương ứng với Array<? super String> của Java. Điều này có nghĩa là bạn có thể truyền một mảng của CharSequence hoặc một mảng của Object vào hàm fill().

3.2 Star-projections

Đôi khi bạn muốn nói rằng bạn không biết gì về đối số kiểu, nhưng bạn vẫn muốn sử dụng nó một cách an toàn. Cách an toàn ở đây là định nghĩa một chiếu của kiểu chung đó, mà mọi hiện thực cụ thể của kiểu chung đó sẽ là một loại con của chiếu đó.
Kotlin cung cấp cú pháp gọi là star-projection cho điều này:
– Đối với Foo, trong đó T là một tham số kiểu covariant với định giới trên TUpper, Foo<*> tương đương với Foo. Điều này có nghĩa là khi T không xác định, bạn có thể an toàn đọc giá trị của TUpper từ Foo<*>.- Đối với Foo, trong đó T là một tham số kiểu contravariant, Foo<*> tương đương với Foo. Điều này có nghĩa là không có gì bạn có thể ghi vào Foo<*> một cách an toàn khi T không xác định.- Đối với Foo, trong đó T là một tham số kiểu invariant với định giới trên TUpper, Foo<*> tương đương với Foo cho việc đọc giá trị và với Foo cho việc ghi giá trị.
Nếu một kiểu chung có nhiều tham số kiểu, mỗi tham số có thể được chiếu độc lập. Ví dụ, nếu kiểu được khai báo như interface Function, bạn có thể sử dụng các chiếu sao sau đây:
* Function<*, String> có nghĩa là Function.
* Function<int, *=""></int,> có nghĩa là Function<int, any?="" out=""></int,>.
* Function<*, *> có nghĩa là Function.

Star-projections khá giống với raw types của Java, nhưng an toàn.

4. Hàm generic

Lớp không phải là duy nhất những khai báo có thể có tham số kiểu. Hàm cũng có thể. Tham số kiểu được đặt trước tên của hàm:

fun <T> singletonList(item: T): List<T> {
    // ...
}

fun <T> T.basicToString(): String { // extension function
    // ...
}

Để gọi một hàm generic, chỉ định các đối số kiểu tại điểm gọi hàm sau tên của hàm:

val l = singletonList(1)

Đối số kiểu có thể được bỏ qua nếu chúng có thể được suy luận từ ngữ cảnh, vì vậy ví dụ sau cũng hoạt động:

val l = singletonList(1)

5. Ràng buộc generic

Tập hợp của tất cả các loại có thể được thay thế cho một tham số kiểu đã cho có thể được hạn chế bởi ràng buộc generic.

5.1 Upper bounds

Loại ràng buộc phổ biến nhất là một upper bound, tương ứng với từ khóa extends trong Java:

fun > sort(list: List) { … }

Loại được chỉ định sau dấu hai chấm là upper bound, chỉ ra rằng chỉ có một loại con của Comparable có thể được thay thế cho T. Ví dụ:

sort(listOf(1, 2, 3)) // OK. Int is a subtype of Comparable
sort(listOf(HashMap<int, string="">())) // Error: HashMap<int, string=""> is not a subtype of Comparable<hashmap<int, string="">>

Upper bound mặc định (nếu không có được chỉ định) là Any?. Chỉ có thể chỉ định một upper bound bên trong dấu ngoặc nhọn. Nếu cùng một tham số kiểu cần nhiều hơn một upper bound, bạn cần một where clause riêng biệt:

fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
    where T : CharSequence,
          T : Comparable<T> {
    return list.filter { it > threshold }.map { it.toString() }
}

Loại được chuyển phải đáp ứng tất cả các điều kiện của where clause cùng một lúc. Trong ví dụ trên, kiểu T phải thực hiện cả hai CharSequenceComparable.</hashmap<int,></int,></int,>

6. Loại generic không thể là null

Để làm cho tương tác với các lớp và giao diện generic Java dễ dàng hơn, Kotlin hỗ trợ việc khai báo một tham số kiểu generic là kiểu nhất quán không thể là null(definitely non-nullable.).
Để khai báo một kiểu generic T nhất quán không thể là null, hãy khai báo kiểu với & Any. Ví dụ: T & Any.
Một loại nhất quán không thể là null phải có một upper bound có thể là null.
Trường hợp sử dụng phổ biến nhất cho việc khai báo loại nhất quán không thể là null là khi bạn muốn ghi đè một phương thức Java chứa@NotNull như một đối số. Ví dụ, xem xét phương thức load():

import org.jetbrains.annotations.*;

public interface Game<T> {
    public T save(T x) {}
    @NotNull
    public T load(@NotNull T x) {}
}


Để ghi đè phương thức load() trong Kotlin một cách thành công, bạn cần khai báo T1 như là nhất quán không thể là null:

interface ArcadeGame<T1> : Game<T1> {
    override fun save(x: T1): T1
    // T1 is definitely non-nullable
    override fun load(x: T1 & Any): T1 & Any
}

Khi chỉ làm việc với Kotlin, ít có khả năng bạn cần phải khai báo rõ ràng các loại nhất quán không thể là null bởi vì ràng buộc loại của Kotlin sẽ quản lý điều này cho bạn.

7. Type erasure

Các kiểm tra an toàn kiểu mà Kotlin thực hiện cho việc sử dụng khai báo generic được thực hiện tại thời điểm biên dịch. Tại thời điểm chạy, các thể hiện của các kiểu generic không giữ bất kỳ thông tin nào về các đối số kiểu thực tế của chúng. Thông tin kiểu được coi là đã bị xoá. Ví dụ, các thể hiện của FooFoo<baz?> được xoá chỉ thành Foo<*>.

7.1 Kiểm tra và ép kiểu kiểu generic

Do việc kiểu bị xoá, không có cách tổng quát nào để kiểm tra liệu một thể hiện của một kiểu generic có được tạo ra với các đối số kiểu cụ thể tại thời điểm chạy hay không, và trình biên dịch cấm những kiểm tra is như ints is List hoặc list is T (tham số kiểu). Tuy nhiên, bạn có thể kiểm tra một thể hiện so với một kiểu được star-projected:

if (something is List<*>) {
    something.forEach { println(it) } // The items are typed as `Any?`
}

Tương tự, khi bạn đã kiểm tra tĩnh các đối số kiểu của một thể hiện (tại thời điểm biên dịch), bạn có thể thực hiện một kiểm tra is hoặc một ép kiểu liên quan đến phần không generic của kiểu. Lưu ý rằng dấu ngoặc nhọn được bỏ qua trong trường hợp này:

fun handleStrings(list: MutableList<String>) {
    if (list is ArrayList) {
        // `list` is smart-cast to `ArrayList<String>`
    }
}

Cú pháp tương tự nhưng với các đối số kiểu được bỏ qua có thể được sử dụng cho các ép kiểu không tính đến đối số kiểu: list as ArrayList.
Các đối số kiểu của các cuộc gọi hàm generic cũng chỉ được kiểm tra tại thời điểm biên dịch. Bên trong cơ thể hàm, các tham số kiểu không thể được sử dụng cho các kiểm tra kiểu, và các ép kiểu đối với tham số kiểu (foo as T) không được kiểm tra. Ngoại trừ duy nhất là các hàm inline với tham số kiểu reified, có các đối số kiểu thực tế được nhúng vào mỗi điểm gọi. Điều này cho phép kiểm tra kiểu và ép kiểu cho các tham số kiểu. Tuy nhiên, các ràng buộc mô tả ở trên vẫn áp dụng đối với các thể hiện của các kiểu generic được sử dụng trong các kiểm tra hoặc ép kiểu. Ví dụ, trong kiểm tra kiểu arg is T, nếu arg là một thể hiện của một kiểu generic, các đối số kiểu của nó vẫn bị xoá.

inline fun <reified A, reified B> Pair<*, *>.asPairOf(): Pair<A, B>? {
    if (first !is A || second !is B) return null
    return first as A to second as B
}

val somePair: Pair<Any?, Any?> = "items" to listOf(1, 2, 3)


val stringToSomething = somePair.asPairOf<String, Any>()
val stringToInt = somePair.asPairOf<String, Int>()
val stringToList = somePair.asPairOf<String, List<*>>()
val stringToStringList = somePair.asPairOf<String, List<String>>() // Compiles but breaks type safety!
// Expand the sample for more details

7.2 Ép kiểu không kiểm tra

Ép kiểu đối với các kiểu generic với đối số kiểu cụ thể như foo as List không thể được kiểm tra tại thời điểm chạy. Các ép kiểu không kiểm tra này có thể được sử dụng khi an toàn kiểu được ngụ ý bởi logic chương trình cấp cao nhưng không thể suy luận trực tiếp bởi trình biên dịch. Xem ví dụ bên dưới.

fun readDictionary(file: File): Map<String, *> = file.inputStream().use {
    TODO("Read a mapping of strings to arbitrary elements.")
}

// We saved a map with `Int`s into this file
val intsFile = File("ints.dictionary")

// Warning: Unchecked cast: `Map<String, *>` to `Map<String, Int>`
val intsDictionary: Map<String, Int> = readDictionary(intsFile) as Map<String, Int>


Cảnh báo xuất hiện cho ép kiểu trong dòng cuối cùng. Trình biên dịch không thể kiểm tra hoàn toàn nó tại thời điểm chạy và không cung cấp bất kỳ đảm bảo nào rằng các giá trị trong bản đồ là Int.
Để tránh ép kiểu không kiểm tra, bạn có thể thiết kế lại cấu trúc chương trình. Trong ví dụ trên, bạn có thể sử dụng các giao diện DictionaryReaderDictionaryWriter với các triển khai an toàn kiểu cho các loại khác nhau. Bạn có thể giới thiệu các trừu tượng hợp lý để chuyển các ép kiểu không kiểm tra từ điểm gọi đến các chi tiết thực hiện. Sử dụng đúng biến thể generic cũng có thể giúp.
Đối với các hàm generic, việc sử dụng tham số kiểu reified khiến các ép kiểu như arg as T được kiểm tra, trừ khi kiểu của argriêng nó các đối số kiểu của nó bị xoá.


Một cảnh báo ép kiểu không kiểm tra có thể được tắt bằng cách đánh dấu câu lệnh hoặc khai báo nơi nó xuất hiện với @Suppress("UNCHECKED_CAST"):

inline fun <reified T> List<*>.asListOfType(): List<T>? =
    if (all { it is T })
        @Suppress("UNCHECKED_CAST")
        this as List<T> else
        null

Trên JVM: kiểu mảng (Array) giữ thông tin về kiểu đã bị xoá của các phần tử của nó, và các ép kiểu thành kiểu mảng được kiểm tra một phần: tính nullability và các đối số kiểu thực tế của kiểu phần tử vẫn bị xoá. Ví dụ, ép kiểu foo as Array<list></list sẽ thành công nếu foo là một mảng chứa bất kỳ List<* nào, có nullable hay không.

8. Toán tử gạch dưới _ cho đối số kiểu

Toán tử gạch dưới _ có thể được sử dụng cho đối số kiểu. Sử dụng nó để tự động suy luận kiểu của đối số khi các kiểu khác được chỉ định một cách rõ ràng:

abstract class SomeClass<T> {
    abstract fun execute() : T
}

class SomeImplementation : SomeClass<String>() {
    override fun execute(): String = "Test"
}

class OtherImplementation : SomeClass<Int>() {
    override fun execute(): Int = 42
}

object Runner {
    inline fun <reified S: SomeClass<T>, T> run() : T {
        return S::class.java.getDeclaredConstructor().newInstance().execute()
    }
}

fun main() {
    // T is inferred as String because SomeImplementation derives from SomeClass<String>
    val s = Runner.run<SomeImplementation, _>()
    assert(s == "Test")

    // T is inferred as Int because OtherImplementation derives from SomeClass<Int>
    val n = Runner.run<OtherImplementation, _>()
    assert(n == 42)
}

Cảm ơn các bạn đã dành thời gian cùng Cafedev đắm chìm trong thế giới sôi động của Kotlin với Generics, nơi chúng ta đã khám phá những khái niệm đặc sắc như `in`, `out`, và `where`. Chúng ta hy vọng rằng thông qua những chia sẻ này, bạn đã có thêm nhiều kiến thức hữu ích và sẽ tiếp tục khám phá sâu hơn về sức mạnh của ngôn ngữ này. Đừng quên tiếp tục đồng hành cùng Cafedev để cập nhật những thông tin mới nhất và chia sẻ đam mê lập trình. Hẹn gặp lại các bạn ở những chia sẻ tiếp theo trên Cafedev!

Các nguồn kiến thức MIỄN PHÍ VÔ GIÁ từ cafedev tại đây

Nếu bạn thấy hay và hữu ích, bạn có thể tham gia các kênh sau của Cafedev để nhận được nhiều hơn nữa:

Chào thân ái và quyết thắng!

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!