iT邦幫忙

2024 iThome 鐵人賽

DAY 7
0

類別的基本結構

如果你有經驗且熟悉 OOP 語言,那麼類別概念應該不會太陌生,可以跳過這章節很多段落。但 Dart 在一些細節上還是有其獨特之處,例如 Dart 如何定義建構子還是需要花點時間,因為和大部分語言有些差異,建議不要略過。

在這個章節,我們將會探討 Dart 的類別,如何建構一個物件實例,繼承,抽象類別,介面,Mixins 。

類別的宣告使用 class 關鍵字,通常會跟著繼承的類別或實作的介面。然後就是封裝的內容。類別成員包含

  • 欄位(Fields):用來存放資料的變數
  • 存取器(Getters/Setters): 用於控制對欄位的讀取和寫入
  • 建構子(Constructors):用於初始化類別實例。Dart 提供了多種建構子語法,包括默認建構子、具名建構子和工廠建構子。欄位通常在這個階段完成初始化。
  • 方法(Methods):物件的行為或功能。

當我們從類別建立了一個物件,通常我們說“實例化”一個類別。換句話說,你建立了一個類別的實例,這個實例就是一個物件或者更明確的說是一個物件實例。通常物件和實例兩個詞是可以互換的。讓我們看一個簡單的例子:

class Person {
  String? firstName;
  String? lastName;
  String getFullName() => "$firstName $lastName";
}

main() {
  Person person = Person();
  person.firstName = 'Clark';
  person.lastName = 'Kent';
  print(person.getFullName());
}
  • 當沒有定義建構子時,Dart 會自動提供一個無參數預設的建構子 Person()

  • 跟許多物件導向語言不一樣,Dart 不需要使用 new 關鍵字,雖然 new 依舊是保留關鍵字。

  • 如果類別沒有明確繼承其他類別,則隱式繼承 Object

  • 類別沒有任何 getset 存取器宣告,那麼我們是如何使用 firstNamelastName 的呢?Dart 會為每個公開的欄位自動生成 getter 和 setter 方法。

雖然 Dart 會自動為公開欄位生成 getter 和 setter,但有時我們需要更精細的控制。我們可以明確定義這些方法:

// 明確顯示寫法
class Person {
String _name;

String get name => _name;
set name(String value) => _name = value;

}

// 隱式 / 自動生成的簡潔語法
// 也就是類別的每個公開欄位都自動具備隱式的 getter 和 setter。
class Person {
String name;

Person(this.name);
}

在這個例子中,我們使用了下劃線 _ 前綴來表示私有變數。Dart 中的私有性是以檔案為界的,而不是類別。

  • 使用 . 可以使用存取內部成員 class.member

  • 箭頭函式的寫法等價於

    String getFullname() => "$firstName $lastName";
    // 等價
    String getFullName() {
      return "$firstName $lastName";
    }
    

    但是箭頭函式相對簡潔容易閱讀。

存取器(Getters 和 Setters)

如同上面提到的,getter 取值方法和 setter 設值方法讓我們可以存取類別的欄位,其每一個欄位都具備存取方法,即便是我們沒有直接宣告定義它們。上面範例的 Person 當我們執行 person.firstName = "Clark",我們實際上是呼叫 setter 並且傳送 "Clark" 作為參數,同樣的,在後續的範例 getter 也用在呼叫 getFullName() 的時候。

class Person {
  String? firstName;
  String? lastName;
  
  String get fullName => "$firstName $lastName";
}

main() {
  Person person = Person();
  person.firstName = "Clark";
  person.lastName = "Kent";
  print(person.fullName);
  person.fullName = "Peter Parker"; // 錯誤,沒有定義 setter
  
}

我們可以從上面例子歸納出一些重點:

  • 我們不能用相同的名稱定義 getter 和 setter - firstNamelastName 這會導致編譯錯誤,因為類別成員的名稱不能重複
  • 我們不一定要成對設定 getter 和 setter,如上面所示,我們單純只定義 fullName getter

如果沒有語法糖的話我們可以如下實作

class Person {
  String? _firstName; // 加入 _ 前綴是 private
  String? _lastName;
  
  // 公開的 getter
  String? get firstName => _firstName;
  String? get lastName => _lastName;
  
  // 公開的 setter
  set firstName(String? value) => _firstName = value;
  set lastName(String? value) => _lastName = value;
  
  String get fullName => "$_firstName $_lastName";
  set fullName(String fullName) {
    var parts = fullName.split(" ");
    this.firstName = parts.first; // 使用公開的 setter
   	_lastName = parts.length > 1 ? parts[1] : ''; // 直接訪問私有成員
  }
}

這種情況下有些人會想直接使用 fullName 設定姓名。

靜態欄位和方法

你可能已經知道,欄位就只是變數,且可以儲存資料,方法就是函式表示物件的某個行為。在某些情況,你可能想要將某個欄位或函式給全部的物件實例使用。針對這種情況,你可以使用靜態修飾子

class Person {
  static String label = 'Person name:';
  String get fullName => "$label $firstName $lastName";
}

因此我們可以從 class 類別直接修改靜態欄位

main() {
  Person personOne = Person();
  personOne.firstName = 'Clark';
  personOne.lastName = 'Kent';
  
  Person personTwo = Person();
  personTwo.firstName = 'Peter';
  personTwo.lastName = 'Parker';
  
  Person.label = 'name: ';
}

靜態欄位是直接通過類別存取的,而不是物件實例。同樣的靜態方法也是。

class Person {
  static String label = "Person: ";
  static void prints(Person p) {
    print("$label ${person.firstName} ${person.lastName}");
  }
}

注意:靜態方法不再能存取實例欄位,例如 firstName 所以要把物件作為參數傳入。

建構子

要初始化一個類別,我們需要呼叫對應的建構子並搭配定義的參數。現在讓我們來修改 Person 類別

class Person {
  late String firstName;
  late String lastName;
  
  Person(String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
  
  String getFullName() => "$firstName $lastName";
}
void main() {
  Person person = Person("Clark", "Kent");
  print(person.getFullName());
}

建構子在 Dart 一樣也是方法,它的作用是初始化物件實例。作為一個方法,它包含一般 Dart 方法常見的特點,例如參數可以支援必填,可選,具名或位置。上面例子有兩個強制必填的參數。

如果你觀察建構子的內容,你會看到它使用 this 關鍵字。這是因為我們要避免疑義。所以我們使用 this 前綴代表讀取的是物件的欄位而不是參數。

在更之前的例子有看到,在 Dart 類別中的方法是可以直接取得欄位的如 String getFullName() => "$firstName $lastName",也就是說如果建構子參數和物件欄位同名那麼物件欄位會被參數取代。

再進一步說,如果在建構子使用 firstName = firstName 我們會搞不清楚誰是代表物件欄位,誰是參數而產生疑義。我們觀察下面例子

class Person {
  String name;
  
  void update(String newName) {
    name = newName; // 因為參數名稱不同,我們不一定要用 this
  }
  
  void setName(String name) {
    // 這裡的 name 是局部變量(參數),遮蔽了實例字段
    // 使用 this.name 指代實例字段,name 指代局部變量
    this.name = name;
  }
}
  • 在 Dart 的類別中,可以直接使用物件欄位的名稱來存取,前提是沒有同名的局部變數遮蔽。
  • 當局部變數(如方法參數)與物件欄位同名時,該局部變數會遮蔽物件欄位。此時,應使用 this 關鍵字來明確指出要存取的是物件欄位。
  • 使用 this 關鍵字有助於提高程式碼的清晰度和可讀性,尤其是在存在同名遮蔽的情況下。

接著注意到我們更之前的例子使用了 late 關鍵字。這是因為欄位在宣告時沒有初始值,且該欄位不能為 null。同時我們知道這些欄位的值會在物件初始化的時候賦予,因此這些值不可以在初始化之前被存取。但如果我們不確定在建構物件的時候保證賦值,那麼我們應該使用 ? 使變數可為 null

⚠️⚠️⚠️ 此外, Dart 還提供另外精簡建構子的寫法:

Person(this.firstName, this.lastName);

如此就不需要完整的建構子主體,適應這種語法會需要點時間,它不只讓建構子更簡潔也減少錯誤,還不需要使用 late 等。

簡單的說上面的語法是建構子的簡化版,如果建構子只是單純用於給欄位賦值,就可以使用。

class Person {
  String firstName;
  String lastName;
  
  Person(this.firstName, this.lastName);
}

// 等同於
class Person {
  String firstName;
  String lastName;
  
  Person(String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

具名建構子

不像其他物件導向語言,Dart 不支援重新定義方法或構造函數來實現重載(不允許同一個類別中有多個同名但參數不同的建構子),因此要定義其他的建構子,你需要使用具名建構子。舉例來說,我們可以加入下嗎的建構子到 Person 類別產生不需要參數的建構子

Person.anonymous();

和方法比較起來,其差異為建構子不需要 return 任何陳述式,因為建構子的任務是初始化物件實例並回傳。後續我們會看到很多具名建構子,因為 Flutter 框架大量用來初始化 Widget,如我們前面看到的 ElevatedButton.icon()

class Person {
  String name;
  
  Person(this.name);
  
  Person.anonymous() {
    name = 'Unknown';
  }
}

工廠模式建構子

建構子不一定總是建立新的物件時,可以使用 factory 建構子。當資料需要快取時可能會用到,因為我們希望從快取回傳而不是建立一個新的物件。換句話說,也就是某些情況我們不建立新物件而是希望回傳一個現有的物件。在某些設計模式中特別實用:

  • 單例模式 Singleton Pattern 全域只有一個物件。
  • 基於快取物件重用。

例如:假設我們快取了 Person 物件。我們可以建立一個 factory 建構子來檢查如果物件已經在快取中則回傳,如果沒有快取則建立一個新物件。

class Person {
	String firstName;
  String lastName;
  
  Person(this.firstName, this.lastName);
  
  factory Person.fromCache(String firstName, String lastName) {
    if (_cache.contains(firstName, lastName)) {
      return _cache.getPerson(firstName, lastName);
    } else {
      return Person(firstName, lastName);
    }
  }
}

注意到 factory 建構子也無法使用 this 關鍵字,因為物件實例還沒建立。

類別繼承

除了隱式繼承 Object 型別外,Dart 可以使用 extends 關鍵字擴展已定義的類別,該類別所有的方法都會被繼承除了建構子。

class Student extends Person {
  String nickName;
  
  Student(
  	String firstName,
    String lastName,
    this.nickName,
  ) : super(firstName, lastName);
  
  @override
  String toString() => "${getFullName()}, aka $nickName";
}

main() {
  Student student = Student('Clark', "Kent", 'Kal-El');
  print(student);
}

這裡有些重要的觀念將協助我們理解後面 Flutter Widget 。

首先,Student 類別定義了自己的建構子。然而,它會將一些建構子中的參數傳給父類別,這部分是靠 super 關鍵字完成。

接著,我們看到 @override 。這是額外描述的方式(Annotation/Metadata)對方法定義。在 Student 類別有一個被覆寫的 toString()。這就是繼承的功用我們改變了父類別繼承到子類別的行為。

為什麼需要 @override 註釋呢?

註釋一般協助增加程式碼的可讀性。在這個例子中,@override 註釋被用來標記這個 toString() 方法的實作覆蓋了父類別中的方法實作。

你可能認為這明顯是為了讓 IDE 可以更方便呈現關係。但這個註釋主要的價值是為了程式檢查。如果你認為你已經覆寫,但實際上你拼錯字了,通過明確的表示你希望覆寫這個方法,編譯器就會知道並進行提示。此外,如果有人變更了上層類別 super 類別的方法,移除了我們希望覆寫的方法,編譯器也會知道。

最後我們在 mainprint(student) ,這裡我們並沒有呼叫 toString() ,這個方法是通過 print方法隱式呼叫。就是當參數不是字串的時候嘗試呼叫 toString

常見覆寫的例子就是 toString 這個方法的目的是回傳物件的字串表示形式,並且它被定義在頂層的 Object 類別上。正如你在前面的範例中所看到的,覆蓋 toString 方法使程式碼更加容易理解,並且我們提供了物件的文本表示,有助於理解日誌、文本格式化等。

抽象類別

在物件導向中,抽象類別是一個類別但不能被實例化。舉例來說我們希望 Person 類別不能直接建立,而只是希望用其來規範希望讓子類別來實例化如 Student 。那麼我們就可以把 Person 設計成抽象類別。

以「動物」為例子來說,Animal 為抽象類別可以定義所有動物的特性,行為,但無法實例化為一個動物,如果是貓,狗就可以。因此把實例化交給子類別的情況適合使用抽象類別。起到了統一一個型別,實現多型等實務上的作用。而抽象類別不只可以規範簽章還可以包含實作方法,因此在這個情況下,比介面(Interface)更合適,可以提供清晰且結構化的類別體系

abstract class Person {
  //
}

main() {
  // 統一介面
  Person student = Student('Clark', 'Kent', 'Kal-EL');
  
  // 錯誤
  Person p = new Person();
}

如你所見,現在我們不能實例化 Person 了。只有子類別可以。

這就是多型的一個主要的例子。就是允許物件可以支援不同的功能,但可以統一介面或型別。雖然我們實例化 Student 物件,但我們可以把它放在 Person 型別的變數中。這種方式在很多情況下很實用,例如當我們程式碼需要一個 Person 型別時,但後續可能是各種子型別依據不同條件對應提供處理。

舉例來說,開學時會有學生,老師,父母出席,然後你需要處理一個關於誰出席的事件。最簡單的方式就是建立不同的子類別。處理事件可以單純的使用 List<Person> 來紀錄。

一個抽象類別可以有抽象成員 - 即不用實作:

abstract class Person {
  String firstName;
  String lastName;
  Person(this.firstName, this.lastName);
  String get fullName;
}

上面的 fullName 方法為抽象的,也就是不用實作,然後子類別需要實作:

abstract class Person {
  String firstName;
  String lastName;

  Person(this.firstName, this.lastName);

  String get fullName; // 抽象方法,子類別需要提供實現
}

class Student extends Person {
  Student(String firstName, String lastName) : super(firstName, lastName);

  @override
  String get fullName => "$firstName $lastName";
}

class Teacher extends Person {
  Teacher(String firstName, String lastName) : super(firstName, lastName);

  @override
  String get fullName => "Mr/Ms. $lastName";
}

class Parent extends Person {
  Parent(String firstName, String lastName) : super(firstName, lastName);

  @override
  String get fullName => "$firstName $lastName (Parent)";
}

void main() {
  List<Person> attendees = [
    Student("John", "Doe"),
    Teacher("Jane", "Smith"),
    Parent("Alice", "Johnson"),
  ];

  for (var person in attendees) {
    print(person.fullName); // 印出每個出席者的全名
  }
}

介面 Interface

介面非常類似於抽象類別,也是規範成員簽章,藉由類別 implements 實作。不稱為繼承是因為它不會包含任何實作,僅僅只是一種規範。

其中的差異為:

  • Dart 類別為單一繼承,而一個類別可以實作多個介面(即便兩個介面有一樣的簽章,因為都是由子類別實作也不會產生衝突的問題,但是只能提供一個實作套用於兩個介面)。
  • 抽象類別可以包含實作,但介面不行。

從 Dart 3 開始才支援 interface 關鍵字。在這之前 Dart 使用一種和傳統物件導向習慣略有不同的方式,就是所有類別宣告時,本身即介面。也就是宣告了一個類別同時也宣告了一個介面,它可以被 implements

class Student implements Person {
  String nickName;
  
  @override
  String firstName;
  @override
  String lastName;
  
  Student(this.firstName, this.lastName, this.nickName);
  
  @override
  String get fullName => "$firstName $lastName";
  String toString() => "$fullName, also known as
    $nickName";
}

實作介面與繼承類別的程式碼結構相似,但有一個關鍵區別:所有的成員都需要在實作類別中明確定義。

當一個類別作為介面使用時(如 Person),它僅僅定義了一個規範或契約,而不包含任何實際的實作邏輯。實作這個介面的類別(如 Student)必須提供所有定義的方法和屬性的具體實作。

Dart 3 引入了 interface 關鍵字,使得介面的定義更加明確。例如:

abstract interface class Person {
  String get fullName;
  String toString();
}

如你所見介面只定義規範;不包含任何實作行為和成員。

有趣的是,如果我們省略 abstract 關鍵字:

interface class Person {
  String get fullName => "Default Name";
  String toString() => "I am a person";
}

這種情況下,Person 既是一個介面,也是一個可以被實例化的類別。也就是說 Dart 的介面不加 abstract 是可以實例化,可以用在共享一樣行為的方法。
但這些預設的實作只有在直接實例化 Person 時才能被使用。任何實作 Person 介面的類別都必須提供自己的實作,不能使用這些預設實作。下面是彙整各種修飾子的圖表:

Mixins

在物件導向中 Mixins 是一種加入功能到類別,而不需要傳統繼承機制的簡便的方案。

Mixins 主要用於需要多重繼承 (Multiple inheritance) 就是需要繼承多個類別的情況,但許多程式語言包含 Dart 不支援傳統意義上的多重繼承,因此提供了 Mixins 這種解決方案。

一個主要的使用情境就是在 Flutter 中建立 Widget 動畫效果時。定義 Widget 類別需要繼承,在單一繼承的情況下我們可以使用 Mixins 加入動畫效果。

Person 類別可以混入指定技能,例如人們有各種技能,混入可以很好的詮釋這點,因為我們不需要為每一種組合定義一個共同的上層類別或介面,直接為人們加入技能,避免重複程式碼。

mixin class ProgrammingSkills {
  coding() {
    
  }
}

mixin class ManagementSkills {
  manage() {
    
  }
}

然後我們可以將它們用 with 關鍵字加入類別

class TechLead extends Person with ProgrammingSkills, ManagementSkills {
  //
}

class JuniorDeveloper extends Person with ProgrammingSkills {
  
}

如此兩個類別都有 coding() 方法而不需要個別實作或實作一個上層類別,而且該方法已經實作了。然後我們幫 TechLead 增加了管理的技能。

理解 StatelessWidget 的建構子

當我們初次接觸 Flutter 中 StatelessWidget 的語法時,可能會感到困惑。讓我們一步步解析這個看似簡單卻不簡單的建構子:

class MyApp extends StatelessWidget {
  const MyApp({ super.key }) // <- 這個詭異的語法到底是怎麼回事?
}

要理解這個語法,我們需要從 Dart 的基礎類別概念開始:

class Person {
  String _name; // 類別中使用 _ 前綴是 private 變數只能在同一個檔案存取
  
  // 提供公開的 getter 和 setter 來讀取和設定 _name
  String get name => _name;
  set name(String value) => _name = value;
  
  // 建構子用來初始化類別實例
  Person(String name) {
   	_name = name;
  }
}

實務上,因為這些 getter 和 setter 會大量重複,因此 Dart 提供簡化的語法糖:

class Person {
  String name; // 類別中的公開變數會自動產生 getter 和 setter
  
  Person(this.name); // 使用 this.name 的語法糖直接初始化 name 變量
}

不可變性和 const 建構子

有了最基礎的類別知識後,接著我們來理解不變性(Immutability),概略的說就是某個變數一旦建立就不能改變,只能整個換掉。Dart 中的 const 建構子 建立的物件是不可變的。這是為了在 Flutter 中這是為了提升效率。

class Person {
  final name; // final 表示 name 一旦設定後不能更改
  
  const Person(this.name); // const 建構子確保 Person 實例物件是不可變的
}

// 建立這種物件
void main() {
  var p = const Person('andyyou');
}

在 Flutter 中使用這種 const 方式建立的組件如果已經在樹狀結構中,則渲染時不會重新建構。

具名參數

接著我們繼續複習關於 Dart 支援的具名參數:

class Person {
  String name;
  
  Person({this.name}); // 使用大括號 { } 定義具名參數
}

// 這和位置參數只有差在實例化的時候可以加入名稱
void main() {
  Person(name: 'andyyou');
}

繼承和傳遞參數給父類別建構子

在繼承情況下,我們可以將參數傳給父類:

class Person {
  String name;
  Person(this.name);
}

class Boss extends Person {
  // ⚠️ 注意這裡我們父類別使用位置參數,子類別使用具名參數。
  Boss({String name}): super(name); // 使用 : super(name) 傳遞 name 給父類別的建構子
}

類似 this 的語法糖概念。Dart 的新版本允許我們使用更簡潔的語法 { super.name } 直接在子類別的建構子中傳遞具名參數給父類別。

class Boss extends Person {
  Boss({ super.name }); // 直接將具名參數傳給父類
}

// 等價於
class Boss extends Person {
  Boss({ String? name }) : super(name: name);
}

結合上面的知識。在 Flutter 中,大部分 Widget 都需要 key 參數控制其在樹狀結構的唯一性。現在我們理解了

class MyApp extends StatelessWidget {
  const MyApp({ super.key }); // 直接將 key 參數傳給 StatelessWidget
}

這裡使用 const MyApp({ super.key }) 表示 MyApp 是一個不可變的 Widget,並且我們通過 { super.key }key 參數傳遞給 StatelessWidget 的建構子。這個 key 是由 Flutter 框架管理的,開發者在建立 Widget 時可以選擇是否提供 keykey 的用途就跟 React 中元件的 key 一樣。當我們有一個動態列表,其中的項目可能會移動、新增或刪除時,使用 key 可以幫助 Flutter 正確地管理這些變化。預設情況下,如果你不提供 key,Flutter 會使用 Widget 的類型和在父層 Widget 中的位置來識別它。多數情況下,我們不需要顯式提供 key,除非你遇到需要精確控制 Widget 標識的特殊情況。

現在,我們可以理解 StatelessWidget 的建構子了;這短短幾行程式碼背後的組成原理。


上一篇
Day 6 Dart 物件導向 (上)
下一篇
Day 8 Dart 深入探索 Flutter 常用特性
系列文
Flutter 開發實戰 - 30 天逃離新手村27
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言