Kotlin 實戰範例 (6) 類別與物件

Kotlin 實戰範例

當應用程式變得龐大,程式碼就會變得複雜,我們需要一種工程方法來處理隨著功能變多而導致複雜度提高的眾多程式碼,解決方式有很多,物件導向是其中一種。

物件導向程式設計藉由對資料抽象及封裝等等特性,讓相關的程式碼藕合在一起,架構出一個由許多程式碼元件組合而成的應用程式。

6.1 類別

類別是一個抽象概念,人類善於將事物抽象後分類,將相似的東西分為同一類,例如各㮔類型的汽車如轎車、休旅車、公車等等雖然都不太一樣,但它們都可以被視為同一類:汽車,汽車會有不同的顏色、配備,這些就是它們的屬性 (變數),汽車可以做到前進、後退及轉向等動作,這些就是它們的行為 (方法);類別定義了屬性及方法,用以描述它所代表的事物的原型,原型無法直接使用,而是透過原型來產生實際上可以使用的物件。

函式在類別中會被稱為方法,變數在類別中則會被稱為屬性或欄位。在類別裡的「東西」都稱為成員 (member),所以又可稱方法為成員函式、屬性稱為成員變數。

在物件導向程式語言中,我們可以將一個類別視為一種資料型別,也說是說,當我們建立一個新的類別時,等同於建立了一個新的資料型別。

要宣告一個類別請使用關鍵字 class

// 宣告一個名為 Car 的類別
class Car {
  // ...  
}

// 無內容的類別
class Empty

如果這個類別沒有任何內容,可以省略大括號。

6.1.1 主要建構式

建構式就是建立物件的函式,我們會呼叫類別的建構式來產生一個物件。任何類別都至少有一個建構式,即使我們沒有宣告任何建構式,預設仍會自行產生一個空的建構式。

Kotlin 的建構式分為主要建構式 (primary constructor) 和次要建構式 (secondary constructors) 兩種。主要建構式只能有一個,次要建構式則可以有多個。主要建構式是類別宣告的一部分,接在類別名稱之後,以關鍵字 constructor 來宣告,請看範例:

class User constructor(name: String) {
  // ...
}

如果主要建構式沒有任何注釋 (annotations) 或存取修飾字 (visibility modifiers),則可以省略關鍵字 constructor,所以大多數情況下我們都會這麼寫:

class User(name: String) {
  // ...
}

主要建構式不能包含任何程式碼,如果有需要,可以使用 init 關鍵字來建立初始化區塊:

class User(name: String) {
  init {
    print("name: $name")
  }
}

初始化區塊屬於主要建構式的一部分,因此可以直接使用主要建構式中的參數,但是如果在初始化區塊中要使用類別的屬性,必須注意將該屬性放在初始化區塊之前,否則會出現屬性未初始化的錯誤警告,請看範例:

class User(name: String) {
  private val id: Int = 1
  init {
    println("$id: $name")
  }
  // 如果寫在這裡,初始化區塊會發生屬性未初始化的錯誤
  // private val id: Int = 1
}

主要建構式的參數會最先建立,然後才依序建立屬性及初始化區塊,因此我們可以在屬性中使用主要建構式的參數,像這樣:

class User(name: String) {
  val id = name.hashCode()
}

請不要把上面這個例子寫成以下這樣:

class User(name: String) {
  val id: Int
  init {
    id = name.hashCode()
  }
}

這兩個例子的結果是一樣的,但是程式碼卻變得比較複雜,請避免這種寫法。

主要建構式的參數不是屬性,所以離開了初始化階段就無法存取,如果要在類別內部的其他地方使用,我們必須在初始化階段將其指派給屬性,例如:

class User(name: String) {
  fun showName() {
    // 錯誤:找不到 name
    // println(name)
  }
}

// 必須改寫成這樣
class User(name: String) {
  private val userName = name
  fun showName() {
    println(userName)
  }
}

這種寫法在 Java 中很常見,但是很麻煩。Kotlin 允許我們將主建構式的參數直接宣告為屬性,只要使用 valvar 來宣告主建構式的參數即可,請看範例:

class User(val name: String) {
  fun showName() {
    println(name)
  }
}

善用參數預設值就能做到類似建構式多載的行為:

class User(val name: String = "", private var age: Int = 0) {
  init {
    if (age < 0) {
      age = 0
    }
  }
  fun showInfo() {
    println("Name: $name, Age: $age")
  }
}
// 看起來像是有 3 種建構式
User("Tony", 5)
User("Tony")
User()

在宣告屬性時可以加上存取修飾字 private,就能限制這個屬性被外界「看到或存取」,省略時預設為 public 表示外界的其他物件能夠看到這個屬性。

最後,如果主要建構式前面有注釋 (annotations) 或存取修飾字 (visibility modifiers),就必須把 constructor 寫出來,像這樣:

class User public @Inject constructor(name: String) {
  // ...   
}
// public 是存取修飾字
// @Inject 是注釋

6.1.2 次要建構式

主建構式只能有一個,如果要宣告其他的建構式,或是不想使用主要建構式,可以在類別裡明確使用 constructor 關鍵字來宣告次要建構式:

class User {
  private val name: String
  constructor(name: String) {
    this.name = name
  }
}

這個例子省略了主要建構式而使用次要建構式,一般來說,我們會優先使用主要建構式,這個例子只是舉例,不建議這麼用。

當我們同時使用主要建構式及多個次要建構式時,每個次要建構式都必須委派主要建構式,或是間接委派其他次要建構式。要委派其他建構式請使用 this 關鍵字,請看範例:

class User(name: String) {
  // 委派主要建構式
  constructor(name: String, age: Int) : this(name) {
    // ...
  }
  
  // 委派另一個次要建構式
  constructor(name: String, age: Int, email: String) :  this(name, age) {
    // ...
  }
}
// 使用情境
User("Tony")
User("Tony", 1)
User("Tony", 1, "abc@mail.com")

要注意的是,初始化區塊是屬於主要建構式的一部分,而次要建構式會直接或間接委派主要建構式,所以初始化區塊的內容會比次要建構式先執行,請看範例:

class User(name: String) {
  init {
    println("Init: $name")
  }
  // 委派主要建構式
  constructor(name: String, age: Int) : this(name) {
    println("$name, $age")
  }
  // 委派另一個次要建構式
  constructor(name: String, age: Int, email: String) : this(name, age) {
    println("$name, $age, $email")
  }
}

fun main() {
  User("Tony")
  // Init: Tony
  
  User("Tony", 1)
  // Init: Tony
  // Tony, 1
  
  User("Tony", 1, "abc@mail.com")
  // Init: Tony
  // Tony, 1
  // Tony, 1, abc@mail.com
}

還有一點要注意,只能在主要建構式中使用 valvar 將參數宣告為屬性,在次要建構式中無法這麼做。

除了抽象類別以外,在沒有明確建立任何建構式的情況下,預設會有一個不帶參數的公開的 (public) 建構式,這樣才能讓該類別被實體化,也就是說以下這個例子是一樣的,都是宣告一個不帶參數的主要建構式:

class User
class User()

如果我們想讓該類別無法被實體化,我們可以將建構式宣告為私有的 (private) :

class User private constructor()
// User() <== 這樣我們就無法實體化此類別

6.1.3 建立類別實體

有了建構式就能用來建立類別實體,即物件。在 Java 中需要使用關鍵字 new 來建立物件,Kotlin 不用,也沒有這個關鍵字,語法看起來有點像是呼叫函式,請看範例:

val user = User()



6.2 繼承

繼承是物件導向的概念,一個類別可以透過繼承另一個類別而擁有相同的程式結構,然後再加上屬於自己的程式碼。繼承的同時兩個類別就擁有父子的關係,在結構上屬於強藕合,一個類別只能繼承一個父類別。

在 Kotlin 中,所有類別的根類別是 Any,當我們建立一個類別但是沒有指定繼承任何類別時,預設就會繼承 Any。這裡要注意的是,Any 類別並不是 java.lang.Object,它除了 equals()hashCode()toString() 三個方法以外什麼都沒有。

繼承一個類別,是在類別宣告的名稱後接冒號 : 再呼叫父類別的建構式,以下為幾個繼承的範例:

/***** 例一 *****/
// 父類別:預設建構式
open class Fruit
// 子類別:預設建構式
class Apple : Fruit()
// 子類別:一個參數的建構式
class Banana(price: Int) : Fruit()

/***** 例二 *****/
// 父類別:一個參數的建構式
open class User(name: String)
// 子類別:一個參數的建構式
class Guest(name: String) : User(name)
// 子類別:無法使用預設(空參數)建構式
// class Admin : User(name) <== 錯誤

/***** 例三 *****/
// 父類別:無參數的主要建構式、兩個次要建構式
open class Employee() {
  constructor(level: Int) : this() {}
  constructor(level: Int, salary: Int) : this(level) {}
}
// 子類別:使用無參數的建構式
class Boss(): Employee()
// 子類別:使用一個參數的建構式
class Operator(level: Int) : Employee(level)
// 子類別:使用兩個參數的建構式
class Manager(level: Int, salary: Int) : Employee(level, salary)

Java 的類別預設是可以被任何類別繼承,除非宣告為 final;Kotlin 則相反,預設不能被繼承,除非指定為 open

繼承可以看成是子類別呼叫父類別的建構式然後才實體化自己,因此父類別中如果有初始化區塊,將會優先被執行,請看以下範例:

// 父類別
open class User {
  init {
    println("Init User")
  }
}

// 子類別
class Guest() : User() {
  init {
    println("Init Guest")
  }
}

fun main() {
  Guest()
  // Init User
  // Init Guest
}

6.2.1 覆寫方法

Kotlin 的設計哲學就是明確,減少任何模糊的空間,因此我們除了類別必須明確指定為 open 才能被繼承外,也必須明確指定方法可以被覆寫,它才能被子類別覆寫,這個規則也和 Java 完全相反。

我們必須先將父類別中的方法設定為 open,子類別才能覆寫該方法;子類別必須使用 override 關鍵字明確表示要覆寫父類別中的方法,請看範例:

open class A {
  open fun a() {} // 可以被覆寫
  fun b() {} // 不能被覆寫
}
class B : A() {
  override fun a() {} // 執行覆寫
  // override fun b() {} // 這樣不行
  // fun b() {} // 這樣也不行
}

override 關鍵字本身是開放的,也就是說當我們對一個方法覆寫時,該方法將等同於被宣告為 open ,因此一個方法一旦被覆寫,在這個類別樹的子類別中將會永遠為 open,如果我們不想讓該方法繼續被其子類別覆寫,必須加上 final 關鍵字,請看範例:

open class A {
  open fun a() {} // 可以被子類別覆寫
}
open class B : A() {
  final override fun a() {} // 禁止子類別覆寫
}
class C : B() {
  // override fun a() {} <== 無法覆寫
}

6.2.2 覆寫屬性

覆寫屬性的規則和覆寫方法相同,但是必須注意以同一種資料型態來覆寫,另外,以 val 宣告的屬性可以被 var 覆寫,但反過來就不行,請看範例:

open class User {
  open val name: String = "" // 以 val 宣告
  open var age: Int = 5      // 以 var 宣告
}
class Guest : User() {
  override var name: String = "" // 可以 var 覆寫
  
  // override val age: Int = 5   // 無法以 val 覆寫
  override var age: Int = 5    // 只能以 var 覆寫
}

主要建構式中宣告的屬性也可以被覆寫,示範如下:

open class User(open val name: String) {
  // ...
}
class Guest(override val name: String) : User(name) {
  // ...
}

6.3 抽象類別

一個以 abstract 關鍵字宣告的類別稱為抽象類別。抽象類別中的屬性必須給初始值,否則就必須宣告為抽象屬性;方法必須有實作內容,否則就必須宣告為抽象方法。一個類別中,只要有一個抽象成員存在,該類別就必須宣告為抽象類別。抽象類別無法實體化,只能被其他類別繼承,繼承抽象類別的子類別必須覆寫所有抽象成員,否則就必須把自己也宣告為抽象類別。請看範例:

// 抽象類別
abstract class A {
  // 抽象屬性
  abstract val a: Int
  // 抽象方法
  abstract fun b()
  // 一般屬性
  val c: String = "C"
  // 一般方法
  fun d() {
    println("D")
  }
}
// 因為沒有覆寫全部的抽象成員,必須也宣告為抽象類別
abstract class B : A() {
  // 覆寫抽象屬性
  override val a: Int = 1
}

6.4 介面

介面是一個物件導向程式語言中的術語,是一個抽象概念,用於定義可提供給外部其他項目使用的功能的規範。

介面使用 interface 關鍵字宣告,可以同時包含抽象方法及有實作內容的方法。介面和抽象類別的結構非常相似,主要的差別在於,介面不能儲存狀態,意即不能使用屬性 (變數) 來儲存資料,介面可以有屬性,但必須是抽象的或提供 getter 及 setter 的實作。

介面被視為一個規範或協定,任何成員都是為了實現它的類別所定義,因此介面中的任何成員都可以被覆寫,不須要特別使用 open 關鍵字,請看範例:

interface A {
  // 抽象屬性
  val a: Int
  // val a: Int = 1 // <== 不能初始化
  
  // 可以實作 getter
  val b: String
    get() = "" 
  
  // 抽象方法:無實作內容
  fun c()
  
  // 可以有實作內容
  fun d() {
    println("D") 
  }
}

6.4.1 實作介面

介面是被用來實作的,它無法被實體化,所以沒有建構式。類別是「實作」介面,所以不需要 (也不能) 在介面名稱後面加上括號,這點和繼承不一樣。一個類別只能繼承一個父類別,但是可以實作多個介面,以逗號分隔,請看範例:

// 實作前例中的介面
class B : A {
  // 必須實作
  override val a: Int = 1
  
  // 依需求覆寫
  override val b: String = "B"
  
  // 必須實作
  override fun c() {
    println("C")
  }
  
  // 依需求覆寫
  override fun d() {
    println("DD")
  }
}

介面的實作方式和繼承一樣,在類別名稱後加上冒號 : 後接介面名稱。介面中任何無實作內容的成員都必須被實作,對於已經有實作內容的成員則可視需求覆寫。

6.4.2 介面的繼承

介面也可以繼承另一個介面,實作子介面的類別,必須連同父介面的成員一起實作,請看範例:

interface A {
  val a: Int
  fun aA()
}
// 繼承 A 介面
interface B : A {
  val b: String
}

// 實作 B 介面
class C : B {
  // 實作 B 的父介面 A 的屬性
  override val a = 1
  // 實作 B 介面的屬性
  override val b = "B"
  // 實作 B 的父介面 A 的方法
  override fun aA() {
    println("aA")
  }
}
// 實作 B 介面,在主要建構式中的寫法
class D(override val a: Int,
        override val b: String) : B {
  override fun aA() {
    println("aA")
  }
}



6.5 Data Class

在物件導向程式設計中,我們經常會建立一個單純用來儲存資料用的類別,實體化此物件的目的就是用來傳遞資料,我們給了它一個術語叫做 DTO (data transfer object) 資料傳遞物件,Kotlin 針對此類物件設計了一個「資料類別」名為 data class

我們先來看看在 Java 中是怎麼做的:

class User {
  private int id;
  private String name;
  private int age;
  private String phone;

  public User(int id, String name, int age, String phone) {
    this.id = id;
    this.name = name;
    this.age = age;
    this.phone = phone;
  }
  
  public int getId() {
    return id;
  }
  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 String getPhone() {
    return phone;
  }
  public void setPhone(String phone) {
    this.phone = phone;
  }
}

// 建立物件
User user1 = new User(1, "Tony", 5, "1234");
User user2 = new User(2, "John", 7, "2345");
User user3 = new User(3, "Roger", 9, "3456");
// user1, user2, user3 就能當成資料來傳遞

DTO 沒什麼特別的,就是一些欄位 (fields) 和可以存取它的 getter/setter 而已。但是輸入這些程式碼不僅浪費時間 (雖然 IDE 有工具可以幫忙),在閱讀上也不容易一眼看出這些欄位的讀寫特性,例如我們將此例中的 id 欄位設定為唯讀,因此沒有 setId() 這個方法,但是如果我們沒有在文件中加以說明,後來的人可能以為忘了寫而加上此方法,如此就可能造成不必要的錯誤。

此外,我們必須自行實作 equals() 方法才能執行正確的物件相等比較,這些細節都是在 Java 中使用 DTO 的一些不方便之處。

Kotlin 提供的 data class 可以幫我們處理這些細節,請看範例:

data class User(val id: Int, 
                var name: String, 
                var age: Int, 
                var phone: String)

我們可以很快的看一眼就知道,被 val 宣告的 id 屬性是唯讀的。使用 data class 不僅可以很有效率的建立屬性,它還幫我們實作了 toString()equals()hashCode() 等方法,讓我們來比較一下和 Java 在使用上的差異:

/* Java */
User user1 = new User(1, "Tony", 3, "123");
User user2 = new User(1, "Tony", 3, "123");
System.out.println(user1.toString()); // User@6d06d69c
System.out.println(user1.hashCode()); // 1829164700
System.out.println(user2.hashCode()); // 2018699554
System.out.println(user1 == user2);   // false
System.out.println(user1.equals(user2)); // false

/* Kotlin */
val user1 = User(1, "Tony", 3, "123")
val user2 = User(1, "Tony", 3, "123")
println(user1.toString()) 
// User(id=1, name=Tony, age=3, phone=123)
println(user1.hashCode()) // -1784135916
println(user2.hashCode()) // -1784135916
println(user1 == user2)   // true
println(user1 === user2)  // false

範例中分別建立了兩個相同內容的物件,我們都知道,每當建立一個全新的物件時,該物件就會被分配到一個記憶體位址,因此這個例子中,兩個 user 物件除了內容相同以外,記憶體位址是不同的。在 Kotlin 的例子中,我們使用 == 來比較結構 (值) 是相等的,使用 === 比較參考 (記憶體位址) 是不相等的。Java 的例子則不管是結構或參考都是不相等的。

toString() 的處理也不一樣,Java 回傳了一段奇怪的內容 User@6d06d69c,這其實是將該物件的 hashCode 以 16 進制表示,但這在實務上沒什麼用,Java 的原始碼如下:

public String toString() {
  return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

Kotlin 則以字串方式回傳了這個物件的屬性及它的值,一般來說這才是我們想要的。Java 中我們必須自行覆寫 toString() 方法來輸出想要的結果,如果每建立一個新類別就要做一次,實在太累人。

equals() 的部分,Java 是繼承自根物件 Object 中的實作,原始碼如下:

public boolean equals(Object obj) {
  return (this == obj);
}

這樣的比較方式,只有在相同物件的情況下才會回傳 true,這也就是為什麼在 Java 中使用 equals() 比較兩個內容相同的不同物件時只會得到 false 的結果。我們可以覆寫比較程式,讓內容相同的物件得到相等的結果,示範如下:

class User {
  // 略... 
  @Override
  public boolean equals(Object o) {
    if (o instanceof User) {
      User u = (User)o;
      return (this.id == u.id && this.name == u.name && 
              this.age == u.age && this.phone == u.phone);
    }
    return false;
  }
}

這樣就可以讓 user1.equals(user2) 出現 true 的結果了,但這樣其實只做了一半,我們還得實作 hashCode() 的部分。這裡必須注意,如果我們有覆寫 equals() 就必須同時覆寫 hashCode() ,因為 hashCode() 有個規定,就是如果用 equals() 判斷為相等物件,它的 hashCode() 回傳的值也必須一樣,反過來也是。(註:這部分的解說會佔用不少篇幅,如果想瞭解更多,請參考電子書中的內容。)

6.5.1 在資料類別內宣告屬性

data class 只會針對主要建構式中的屬性來產生相對應的程式碼,如果我們有不想讓編譯器自動產生相對應程式碼的屬性,請在資料類別裡面宣告,請看範例:

data class A(val a: Int) {
  // 在資料類別內宣告的屬性,不會被用來產生相對應的程式碼
  var b: Int = 0
}

val a1 = A(1)
a1.b = 1
val a2 = A(1)
a2.b = 2
println(a1.toString())
println(a1 == a2)
// [結果]
// A(a=1)
// true

我們可以看到 toString() 的結果裡面沒有 b 屬性,而且我們故意把 b 屬性的值設成不一樣,可是等式比較卻回傳 true ,這是因為屬性 b 被自動產生的程式碼忽略,包括 equals()hashCode()copy() 方法中都會忽略,而且只會有一個 componentN() 函式 。

6.5.2 物件拷貝

有時候我們會需要建立一個類似的物件,只有其中某個屬性值不一樣,如果一個一個建立也是可以,就是沒效率。我們可以使用 copy() 方法來拷貝一個一模一樣的物件,然後再去修改某個屬性的值,請看以下範例:

// 宣告一個資料類別
data class User(val id: Int, 
                var name: String, 
                var age: Int, 
                var phone: String)
// 建立物件
val tony = User(id = 1, 
                name = "Tony", 
                age = 5, 
                phone = "123")
// 拷貝物件
val tony2 = tony.copy()
// 等式比較
println(tony == tony2) // true, 因為內容相同
println(tony === tony2) // false, 因為是不同物件

// 我們可以在拷貝物件的同時,修改某個屬性值
val tony3 = tony.copy(age = 7)


繼續閱讀:Kotlin 實戰範例 (7) 高階函式

完整內容可以參考電子書:Google Play Pubu 樂天 Kobo



本文網址:https://blog.tonycube.com/2020/08/kotlin-by-example-6-class-and-object.html
Tony Blog 撰寫,請勿全文複製,轉載時請註明出處及連結,謝謝 😀

我要留言

留言小提醒:
1.回覆時間通常在晚上,如果太忙可能要等幾天。
2.請先瀏覽一下其他人的留言,也許有人問過同樣的問題。
3.程式碼請先將它編碼後再貼上。(線上編碼:http://bit.ly/1DL6yog)
4.文字請加上標點符號及斷行,難以閱讀者恕難回覆。
5.感謝您的留言,您的問題也可能幫助到其他有相同問題的人。