Kotlin基礎:望文生義的Kotlin集合操作

唐子玄發表於2019-05-16

這是該系列的第二篇,系列文章目錄如下:

  1. Kotlin基礎:白話文轉文言文般的Kotlin常識

  2. Kotlin基礎:望文生義的Kotlin集合操作

  3. Kotlin實戰:用實戰程式碼更深入地理解預定義擴充套件函式

  4. Kotlin實戰:使用DSL構建結構化API去掉冗餘的介面方法

  5. Kotlin基礎:屬性也可以是抽象的

  6. Kotlin進階:動畫程式碼太醜,用DSL動畫庫拯救,像說話一樣寫程式碼喲!

  7. Kotlin基礎:用約定簡化相親

  8. Kotlin基礎 | 2 = 12 ?泛型、類委託、過載運算子綜合應用

有沒有那麼一種程式碼,從頭到尾讀一遍就能清晰的明白語義?就好像在閱讀英語文章一樣。這篇文章就試著用這樣望文生義的程式碼來實現業務需求,剖析 kotlin 語言特性所帶來的簡潔及其背後原理。知識點包括序列,集合操作,主構造方法,可變引數,預設引數,命名引數,for迴圈,資料類。本著實用主義,不會面面俱到地展開知識點所有的細節(這樣會很無趣),而是隻講述和例項有關的方面。

該系列每一篇例子用到的知識點會在上一篇的基礎上擴充,若遇到不了解的語法也可以移步上一篇查閱。

業務需求如下:假設現在需要基於學生列表過濾出所有學生的選修課(課時數 < 70),輸出時按課時數升序排列,課時數相等的再按課程名字母序排列,並寫課程名的第一個字母。

資料類

先得宣告資料實體類,java的程式碼如下:

課程實體類

public class Course {
    private String name ;
    private int period ;
    private boolean isMust;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getPeriod() {
        return period;
    }

    public void setPeriod(int period) {
        this.period = period;
    }

    public boolean isMust() {
        return isMust;
    }

    public void setMust(boolean must) {
        isMust = must;
    }

    @Override
    public String toString() {
        return "Course{" +
                "name=‘" + name + '\'' +
                ", period=" + period +
                ", isMust=" + isMust +
                '}’;
    }
}
複製程式碼

學生實體類

public class Student {
    private String name;
    private int age;
    private boolean isMale ;
    private List<Course> courses ;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public boolean isMale() {
        return isMale;
    }

    public void setMale(boolean male) {
        isMale = male;
    }

    public List<Course> getCourses() {
        return courses;
    }

    public void setCourses(List<Course> courses) {
        this.courses = courses;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                ", isMale=" + isMale +
                ", courses=" + courses +
                '}‘;
    }
}
複製程式碼

程式碼略長,其實關鍵資訊只有兩個:類名+屬性,其餘部分都是模版程式碼,所以 kotlin 將資料類的定義縮減成一行程式碼:

data class Course constructor(var name: String, var period: Int, var isMust: Boolean = false)

data class Student constructor(var name: String, var age: Int, var isMale: Boolean, var courses: List<Course> = listOf())
複製程式碼
  • data是保留字,用於修飾一個類,表明該類只包含資料而不包含行為,即是 java 中的 Bean 類。
  • 類的宣告格式如下:
修飾詞 class 類名 constructor(主建構函式引數列表){類體}
複製程式碼
  • class保留字用於宣告類。
  • constructor保留字用於宣告類的主構造方法,它相當於把 java 中的類宣告和建構函式宣告合併到了一行,下面的兩段程式碼是完全等價的:
//java
class A(){
    private int i
    A(int i){
        this.i = i;
    }
}

//kotlin
class A constructor(var i: Int)
複製程式碼
  • 主構造方法顯示地宣告瞭類的成員屬性和其資料型別,這裡包含的隱藏資訊是,當構造A的例項時,傳入構造方法的 Int 值會被賦值給成員 i。
  • 除了簡單地為成員賦值,主構造方法不包含其他任何邏輯。(當需要特殊的初始化邏輯時需要使用別的方法,以後會講到~)
  • 當沒有可見性修飾符修飾主構造方法時,可以省去constructor保留字,所以上面的資料類可以簡化成:
data class Course(var name: String, var period: Int, var isMust: Boolean = false)

data class Student(var name: String, var age: Int, var isMale: Boolean, var courses: List<Course> = listOf())
複製程式碼

這裡還展示了一種在 java 中不支援的特性:引數預設值Course類的isMust屬性的預設值是false,這減少了過載建構函式的數量,因為在 java 中只能通過過載來實現:

public Course{
    public Course(String name,int period,boolean isMust){
        this.name = name;
        this.period = period;
        this.isMust = isMust;
    }
    
    public Course(String name,int period){
        return Course(name,period,false);
    }
}
複製程式碼

在簡簡單單的一句類宣告的背後,編譯器會自動為我們建立所有我們需要的方法,包括:

  • setter() 和 getter()
  • equals() 和 hashCode()
  • toString()
  • copy()

其中copy()會基於物件現有屬性值構建一個新物件。

構建集合

有了資料實體類後,就可以構建資料集合了,讓我們來構建一個包含4個學生的列表,java 程式碼如下(其實直接跳過這段程式碼也是不錯的選擇,因為它很冗長而且可讀性差):

Student student1 = new Student();
student1.setName("taylor");
student1.setAge(33);
student1.setMale(false);
List<Course> courses1 = new ArrayList<>();
Course course1 = new Course();
course1.setName("pysics");
course1.setPeriod(50);
course1.setMust(false);
Course course2 = new Course();
course2.setName("chemistry");
course2.setPeriod(78);
courses1.add(course1);
courses1.add(course2) ;
student1.setCourses(courses1);

Student student2 = new Student();
student2.setName("milo");
student2.setAge(20);
student2.setMale(false);
List<Course> courses2 = new ArrayList<>();
Course course3 = new Course();
course3.setName("computer");
course3.setPeriod(50);
course3.setMust(true);
student2.setCourses(courses2);

List<Student> students = new ArrayList<>();
students.add(student2);
students.add(student1);
...
複製程式碼

我只寫了2個學生構建程式碼,不想再寫下去了。。。你能不能一眼看出它到底在構建啥嗎?

還是看看 kotlin 是怎麼玩的吧:

val students = listOf(
    Student("taylor", 33, false, listOf(Course("physics", 50), Course("chemistry", 78))),
    Student("milo", 20, false, listOf(Course("computer", 50, true))),
    Student("lili", 40, true, listOf(Course("chemistry", 78), Course("science", 50))),
    Student("meto", 10, false, listOf(Course("mathematics", 48), Course("computer", 50, true)))
)
複製程式碼

就算是第一次接觸 kotlin ,一定也看懂這是在幹嘛。

  • 得益於引數預設值,對於同一個Course建構函式,可傳入2個引數Course("physics", 50),也可傳入3個引數Course("computer", 50, true)
  • listOf()是 kotlin 標準庫中的方法,這個方法極大簡化了構建集合的程式碼,看下它的原始碼:
public fun <T> listOf(vararg elements: T): List<T> = if (elements.size > 0) elements.asList() else emptyList()
複製程式碼
  • vararg保留字用於修飾可變引數,表示這個該函式可以接收任意數量的該類引數。
  • listOf()的返回值是 kotlin 中的List型別。

一眼看去,我們就能知道這段程式碼構建了一個列表,列表中構建了4個學生例項,在構建學生例項的同時構建了一系列課程例項。

但是構建學生時,傳入的布林值是什麼語義?猜測可能是年齡,在 IDE 跳轉功能的幫助下,可以方便地到Student定義處確認一下。但如果在網頁端進行 Code Review 時就沒有這麼好的條件了。

有什麼辦法在方法呼叫處就指明引數的語義?

命名引數功能就是為此而生,上面的程式碼還可以這樣寫:

val students = listOf(
    Student("taylor", 33, isMale = false, courses = listOf(Course("physics", 50), Course("chemistry", 78))),
    Student("milo", 20, isMale = false, courses = listOf(Course("computer", 50, true))),
    Student("lili", 40, isMale = true, courses = listOf(Course("chemistry", 78), Course("science", 50))),
    Student("meto", 10, isMale = false, courses = listOf(Course("mathematics", 48), Course("computer", 50, true)))
)
複製程式碼

可以在引數前通過加變數名 =的方式來顯示指明引數語義,同時這對變數的命名也提出了更高的要求。

作為程式設計師的我們,絕大部分時間不是在寫而是在讀別人或自己的程式碼。就好像語文閱卷老師要讀大量作文一樣,如果字跡潦草,段落不清晰,就是在給自己給老師添麻煩。同樣的,達意的命名,一致的縮排,語義清晰的呼叫,讓自己和同事賞心悅目。(這也是 kotlin 為啥能提高產生效率的原因,因為它更簡潔,更可讀)

操縱集合

下一個步驟是操縱集合,直接上 kotlin :

val friends = students
        .flatMap { it.courses }
        .toSet()
        .filter { it.period < 70 && !it.isMust }
        .map {
            it.apply {
                name = name.replace(name.first(), name.first().toUpperCase())
            }
        }
        .sortedWith(compareBy({ it.period }, { it.name }))
複製程式碼

掃了一遍,在很多陌生函式裡面有一個上篇講解過的apply(),它做的事情是將集合中的每個元素中的name屬性的第一個字元換成大寫。

在 java 中(8.0以前),為了操縱集合元素,必然要用for迴圈遍歷集合。但在上面的程式碼中,沒有發現類似的遍歷操作,那 kotlin 是如何獲取集合中元素的?

map()

kotlin 標準庫中預定了很多集合操縱方法,上面用到的map()就是其中一個,它的原始碼如下:

public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
    return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}
複製程式碼

map()是一個Iterable類的擴充套件函式,這個類表示一個可以被迭代的物件,CollectionList都是繼承自它:

/**
 * Classes that inherit from this interface can be represented as a sequence of elements that can
 * be iterated over.
 * @param T the type of element being iterated over. The iterator is covariant on its element type.
 */
public interface Iterable<out T> {
    /**
     * Returns an iterator over the elements of this object.
     */
    public operator fun iterator(): Iterator<T>
}

public interface Collection<out E> : Iterable<E> {
    ...
}

public interface List<out E> : Collection<E> {
    ...
}
複製程式碼

map()內會新建一個ArrayList型別的集合(它是一箇中間臨時集合)並傳給mapTo()

public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
    for (item in this)
        destination.add(transform(item))
    return destination
}
複製程式碼

這裡出現了一個熟悉的保留字for,它in搭配後和 java 中的for-each語義類似。

原來map()內部使用了for迴圈遍歷源集合,並在每個元素上應用了transform這個變換,最後將變換後的元素加入臨時集合中並將其返回。

所以map()函式的語義是:在集合的每一個元素上應用一個自定義的變換

filter()

map()函式前呼叫了filter(),原始碼如下:

public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

public inline fun <T, C : MutableCollection<in T>> Iterable<T>.filterTo(destination: C, predicate: (T) -> Boolean): C {
    for (element in this) if (predicate(element)) destination.add(element)
    return destination
}
複製程式碼

類似的,它也會構建一個臨時集合來暫存運算的中間結果,在遍歷源集合的同時應用條件判斷predicate,當滿足條件時才將源集合元素加入到臨時集合。

所以filter()的語義是:只保留滿足條件的集合元素

toSet()

filter()之前呼叫是toSet()

public fun <T> Iterable<T>.toSet(): Set<T> {
    if (this is Collection) {
        return when (size) {
            0 -> emptySet()
            1 -> setOf(if (this is List) this[0] else iterator().next())
            else -> toCollection(LinkedHashSet<T>(mapCapacity(size)))
        }
    }
    return toCollection(LinkedHashSet<T>()).optimizeReadOnlySet()
}

public fun <T, C : MutableCollection<in T>> Iterable<T>.toCollection(destination: C): C {
    for (item in this) {
        //重複元素會新增失敗
        destination.add(item)
    }
    return destination
}
複製程式碼

遍歷源集合的同時藉助LinkedHashSet來實現元素的唯一性。

所以toSet()的語義是:將集合元素去重

flatMap()

在呼叫鏈的最開始,呼叫的是flatMap()

public inline fun <T, R> Iterable<T>.flatMap(transform: (T) -> Iterable<R>): List<R> {
    return flatMapTo(ArrayList<R>(), transform)
}

public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.flatMapTo(destination: C, transform: (T) -> Iterable<R>): C {
    for (element in this) {
        val list = transform(element)
        destination.addAll(list)
    }
    return destination
}
複製程式碼

flatMap()的原始碼和map()非常相似,唯一的區別是,transform這個變換的結果是一個集合型別,然後會把該集合整個加入到臨時集合。

flatMap()做了兩件事情:先對源集合中每個元素做變換(變換結果是另一個集合),然後把多個集合合併成一個集合。這樣的操作非常適用於集合中套集合的資料結構,就好像本例中的學生例項存放在學生列表中,而每個學生例項中包含課程列表。通過先變換後平鋪的操作可以方便地將學生列表中的所有課程平鋪開來。

所以flatMap()的語義是:將巢狀集合中的內層集合鋪開

asSequence()

因為每個操縱集合的函式都會新建一個臨時集合以存放中間結果。

為了更好的效能,有沒有什麼辦法去掉臨時集合的建立?

序列就是為此而生的,用序列改寫上面的程式碼:

val friends = students.asSequence()
        .flatMap { it.courses.asSequence() }
        .filter { it.period < 70 && !it.isMust }
        .map {
            it.apply {
                name = name.replace(name.first(), name.first().toUpperCase())
            }
        }
        .sortedWith(compareBy({ it.period }, { it.name }))
        .toSet()
複製程式碼

通過呼叫asSequence()將原本的集合轉化成一個序列,序列將對集合元素的操作分為兩類:

  1. 中間操作
  2. 末端操作

從返回值上看,中間操作返回的另一個序列,而末端操作返回的是一個集合(toSet()就是末端操作)。

從執行時機上看,中間操作都是惰性的,也就說中間操作都會被推遲執行。而末端操作觸發執行了所有被推遲的中間操作。所以將toSet()移動到了末尾。

序列還會改變中間操作的執行順序,如果不用序列,n 箇中間操作就需要遍歷集合 n 遍,每一遍應用一個操作,使用序列之後,只需要遍歷集合 1 遍,在每個元素上一下子應用所有的中間操作。

如果用 java 實現上述集合操作的話,需要定義一個不是太簡單的演算法,定神分析一番才能明白業務需求,而 kotlin 的程式碼就好像把需求翻譯成了英語,順著讀完程式碼就能明白語義。這種 “望文生義” 的效果,真是 java 不能比擬的。

知識點總結

  • 通過data關鍵詞配合主建構函式,kotlin 可以用一行程式碼宣告資料類。
  • 主構造方法是一個用於為類屬性賦初始值的構造方法。它通過constructor保留字和類頭宣告在同一行。
  • 保留字vararg用於宣告可變引數,帶有可變引數的方法可以接收任意個數的引數。
  • 可以通過=在宣告方法時為引數設定預設值,以減少過載函式。
  • 可以通過變數名 =語法在方法呼叫的時候新增命名引數,增加方法呼叫的可讀性。
  • kotlin 標準庫預定義了很多處理集合的方法,其中
    • filter()的語義是:只保留滿足條件的集合元素
    • toSet()的語義是:將集合元素去重
    • flatMap()的語義是:將巢狀集合中的內層集合鋪開
    • map()函式的語義是:在集合的每一個元素上應用一個自定義的變換
    • asSequence()用於將一連串集合操作變成序列,以提升集合操作效能。

相關文章