如果你有經驗且熟悉 OOP 語言,那麼類別概念應該不會太陌生,可以跳過這章節很多段落。但 Dart 在一些細節上還是有其獨特之處,例如 Dart 如何定義建構子還是需要花點時間,因為和大部分語言有些差異,建議不要略過。
在這個章節,我們將會探討 Dart 的類別,如何建構一個物件實例,繼承,抽象類別,介面,Mixins 。
類別的宣告使用 class
關鍵字,通常會跟著繼承的類別或實作的介面。然後就是封裝的內容。類別成員包含
當我們從類別建立了一個物件,通常我們說“實例化”一個類別。換句話說,你建立了一個類別的實例,這個實例就是一個物件或者更明確的說是一個物件實例。通常物件和實例兩個詞是可以互換的。讓我們看一個簡單的例子:
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
類別沒有任何 get
和 set
存取器宣告,那麼我們是如何使用 firstName
和 lastName
的呢?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";
}
但是箭頭函式相對簡潔容易閱讀。
如同上面提到的,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
}
我們可以從上面例子歸納出一些重點:
firstName
和 lastName
這會導致編譯錯誤,因為類別成員的名稱不能重複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;
}
}
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
建構子。當資料需要快取時可能會用到,因為我們希望從快取回傳而不是建立一個新的物件。換句話說,也就是某些情況我們不建立新物件而是希望回傳一個現有的物件。在某些設計模式中特別實用:
例如:假設我們快取了 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
類別的方法,移除了我們希望覆寫的方法,編譯器也會知道。
最後我們在 main
,print(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); // 印出每個出席者的全名
}
}
介面非常類似於抽象類別,也是規範成員簽章,藉由類別 implements
實作。不稱為繼承是因為它不會包含任何實作,僅僅只是一種規範。
其中的差異為:
從 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 主要用於需要多重繼承 (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
增加了管理的技能。
當我們初次接觸 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 變量
}
有了最基礎的類別知識後,接著我們來理解不變性(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 時可以選擇是否提供 key
。key
的用途就跟 React 中元件的 key
一樣。當我們有一個動態列表,其中的項目可能會移動、新增或刪除時,使用 key
可以幫助 Flutter 正確地管理這些變化。預設情況下,如果你不提供 key
,Flutter 會使用 Widget 的類型和在父層 Widget 中的位置來識別它。多數情況下,我們不需要顯式提供 key
,除非你遇到需要精確控制 Widget 標識的特殊情況。
現在,我們可以理解 StatelessWidget
的建構子了;這短短幾行程式碼背後的組成原理。