一次掌握 Kotlin 物件導向程式設計
昨天我們認識了 Array、List、Set、Map 等集合類型,學會如何管理多筆資料。今天要進入 Kotlin 非常重要的「物件導向程式設計」,從類別、物件、屬性、方法開始,一路介紹 getter/setter、建構子、繼承、方法覆寫、向上轉型與多型。參考資料:Kotlin Classes
今天使用的課堂範例:
專案程式碼總目錄:Kotlin/0616/KT_3/src
這些連結使用你指定的 commit 8e6d0e5986e77c2a354935663126c973de621bc1,因此之後即使 main 分支內容改變,文章仍會連到這一版程式碼。參考資料:GitHub—About commits
類別(Class)可以看成建立物件的設計圖,裡面定義物件擁有哪些資料,以及能夠執行哪些操作。根據類別建立出來的實體,稱為物件(Object)或執行個體(Instance)。參考資料:Kotlin Classes
以遊戲玩家為例,每一位玩家都有姓名與分數,也能執行顯示資料的動作,因此可以建立一個 PlayerClass 類別:
class PlayerClass {
// 玩家姓名屬性。
var name: String = ""
// 玩家分數屬性。
var score: Int = 0
// 顯示玩家目前的姓名與分數。
fun show() {
println("$name,score = $score")
}
// 修改玩家資料。
// newScore 有預設值,因此呼叫時可以只修改姓名。
fun setData(
newName: String,
newScore: Int = score
) {
name = newName
score = newScore
}
}
fun main() {
// 使用 PlayerClass() 建立第一個玩家物件。
val player1 = PlayerClass()
player1.name = "Mary"
player1.score = 100
player1.show()
// 建立第二個獨立的玩家物件。
val player2 = PlayerClass()
player2.name = "John"
player2.score = 80
player2.show()
// 只傳入姓名,分數會保留原本的 80。
player2.setData(newName = "Jack")
player2.show()
// 同時修改姓名與分數。
player2.setData(
newName = "Jane",
newScore = 88
)
player2.show()
}
執行結果:
Mary,score = 100
John,score = 80
Jack,score = 80
Jane,score = 88
player1 與 player2 雖然都由 PlayerClass 建立,但它們是兩個不同物件,各自擁有自己的 name 與 score。修改其中一個物件,不會影響另一個物件。參考資料:Kotlin Classes
類別裡通常包含兩種主要成員:屬性(Property)與方法(Method)。屬性負責保存物件狀態,例如學生姓名、年齡及成績;方法則負責執行操作,例如判斷是否及格或列印學生資料。參考資料:Kotlin Properties、Kotlin Functions
以下是完整的學生類別:
class Student {
// 學生姓名。
var name: String = ""
// 學生年齡。
var age: Int = 0
// 學生成績。
var score: Int = 0
// 判斷成績是否達到 60 分。
fun isPass(): Boolean {
return score >= 60
}
// 顯示學生的完整資料。
fun printInfo() {
val passStatus = if (isPass()) "是" else "否"
println(
"姓名:$name,年齡:$age,成績:$score,及格:$passStatus"
)
}
}
fun main() {
val student1 = Student()
student1.name = "Tom"
student1.age = 18
student1.score = 85
val student2 = Student()
student2.name = "Amy"
student2.age = 17
student2.score = 92
val student3 = Student()
student3.name = "Bob"
student3.age = 19
student3.score = 55
student1.printInfo()
student2.printInfo()
student3.printInfo()
}
執行結果:
姓名:Tom,年齡:18,成績:85,及格:是
姓名:Amy,年齡:17,成績:92,及格:是
姓名:Bob,年齡:19,成績:55,及格:否
把 isPass() 放進 Student 類別後,及格判斷就成為學生物件本身具備的能力,不需要在主程式裡重複撰寫相同條件。這就是把資料與操作封裝在同一個類別中的基本概念。參考資料:Kotlin Classes
類別除了保存人物或商品資料,也能封裝計算邏輯。矩形具有寬度與高度,並可以根據這兩項屬性計算面積及周長。參考資料:Kotlin Member Functions
class Rectangle {
// 矩形寬度。
var width: Int = 0
// 矩形高度。
var height: Int = 0
// 計算矩形面積。
fun area(): Int {
return width * height
}
// 計算矩形周長。
fun perimeter(): Int {
return (width + height) * 2
}
}
fun main() {
val rectangle1 = Rectangle()
rectangle1.width = 120
rectangle1.height = 30
val rectangle2 = Rectangle()
rectangle2.width = 250
rectangle2.height = 60
println("--- 矩形 1 ---")
println("寬:${rectangle1.width}")
println("高:${rectangle1.height}")
println("面積:${rectangle1.area()}")
println("周長:${rectangle1.perimeter()}")
println()
println("--- 矩形 2 ---")
println("寬:${rectangle2.width}")
println("高:${rectangle2.height}")
println("面積:${rectangle2.area()}")
println("周長:${rectangle2.perimeter()}")
}
執行結果:
--- 矩形 1 ---
寬:120
高:30
面積:3600
周長:300
--- 矩形 2 ---
寬:250
高:60
面積:15000
周長:620
Kotlin 的 var 屬性預設具有 getter 與 setter。getter 負責讀取屬性,setter 負責寫入屬性;我們也可以自訂它們,在讀取或寫入資料時進行轉換與驗證。參考資料:Kotlin Getters and Setters
import java.util.Locale
class ToUpper {
var text: String = ""
// 寫入資料時進行檢查與轉換。
set(value) {
field = if (value.isEmpty()) {
"No data"
} else {
value.uppercase(Locale.getDefault())
}
}
// 讀取資料時加上提示文字。
get() {
return "transfer data:$field"
}
}
fun main() {
val data = ToUpper()
data.text = "hello, Mary"
println(data.text)
data.text = ""
println(data.text)
}
執行結果:
transfer data:HELLO, MARY
transfer data:No data
在自訂存取子裡,field 代表屬性真正保存資料的幕後欄位(Backing Field)。如果在 setter 裡再次寫成 text = value,就會不斷呼叫自己而造成遞迴,因此必須使用 field 存取實際內容。參考資料:Kotlin Backing Fields
class Player {
// private 代表只能在 Player 類別內直接存取。
private var phone: String = "123456"
var name: String = "Mary"
get() = field.uppercase()
set(value) {
// 去除姓名前後的空白。
field = value.trim()
}
var id: Int = 1
get() = field + 100
set(value) {
if (value > 10) {
println("ID 錯誤:ID 必須小於或等於 10")
field = 0
} else {
field = value
}
}
var email: String = "aaa@gmail.com"
get() = field.lowercase()
set(value) {
if ("@" in value) {
field = value
} else {
println("電子郵件格式錯誤")
}
}
// code 本身不另外保存資料,而是透過 id 進行轉換。
var code: Int
get() = id - 100
set(value) {
id = value
}
// 外部不能直接修改 phone,
// 因此提供公開方法控制修改流程。
fun changePhone(newPhone: String) {
phone = newPhone
}
fun printInfo() {
println(
"name = $name,id = $id," +
"email = $email,phone = $phone"
)
}
}
fun main() {
val player = Player()
player.printInfo()
player.name = " John "
player.id = 2
player.email = "John@Example.COM"
player.printInfo()
println("code = ${player.code}")
// 超過允許範圍,setter 會顯示錯誤並將實際值設成 0。
player.id = 15
println("id = ${player.id}")
// 透過公開方法修改 private 屬性。
player.changePhone("0912-345-678")
player.printInfo()
}
這個設計展示了封裝(Encapsulation)的概念:物件不必把所有資料完全公開,而是透過 setter 或公開方法限制資料的修改方式,避免物件進入不合理的狀態。參考資料:Kotlin Visibility Modifiers
建構子(Constructor)用來在建立物件時接收初始資料。寫在類別名稱後面的建構子稱為主建構子(Primary Constructor)。參考資料:Kotlin Constructors
class NewPlayer(
var name: String,
var age: Int,
var healthPoint: Int = 80
) {
fun printInfo() {
println(
"name = $name,age = $age," +
"healthPoint = $healthPoint"
)
}
}
fun main() {
// 三個參數全部傳入。
val player1 = NewPlayer(
name = "John",
age = 22,
healthPoint = 90
)
player1.printInfo()
// healthPoint 使用預設值 80。
val player2 = NewPlayer(
name = "Mary",
age = 30
)
player2.printInfo()
}
如果主建構子的參數前面加上 var 或 val,Kotlin 會直接將參數宣告成類別屬性。var 代表建立後仍能修改,val 則代表建立後不能重新指定。參考資料:Kotlin Primary Constructor
主建構子本身不能直接放置一般程式敘述。如果建立物件時需要執行驗證、轉換或顯示訊息,可以使用 init 初始化區塊。參考資料:Kotlin Initializer Blocks
class GamePlayer(
playerName: String,
playerAge: Int,
blessed: Boolean
) {
var name: String = playerName
set(value) {
if (value.length > 10) {
println("姓名長度不能超過 10 個字元")
field = "Error"
} else {
field = value
}
}
var age: Int = playerAge
var isBlessed: Boolean = blessed
init {
println("正在建立玩家物件……")
println("初始玩家:$name")
}
fun printInfo() {
println(
"name = $name,age = $age," +
"isBlessed = $isBlessed"
)
}
}
fun main() {
val player = GamePlayer(
playerName = "Mary",
playerAge = 18,
blessed = true
)
player.printInfo()
// 這個姓名超過 10 個字元,會經過 setter 驗證。
player.name = "Mary123456789"
player.printInfo()
}
init 會在物件建立時自動執行,而且會依照它在類別中出現的順序,與屬性初始化程式一起執行。參考資料:Kotlin Initializer Blocks
當類別需要支援多種建立方式時,可以使用 constructor 宣告次要建構子(Secondary Constructor)。每個次要建構子最終都必須直接或間接呼叫主建構子。參考資料:Kotlin Secondary Constructors
以下的 Info 可以只接收姓名與年齡,也能額外接收電子郵件與電話:
class Info(
name: String,
age: Int
) {
var name: String = name
var age: Int = age
var email: String = "No Email"
var phone: String = "No Phone"
// 第一個次要建構子:
// 接收姓名、年齡與電子郵件。
constructor(
name: String,
age: Int,
email: String
) : this(name, age) {
// 電子郵件必須包含 @。
if ("@" in email) {
this.email = email
}
}
// 第二個次要建構子:
// 接收姓名、年齡、電子郵件與電話。
constructor(
name: String,
age: Int,
email: String,
phone: String
) : this(name, age, email) {
// 驗證台灣市內電話的簡化格式:4 碼-4 碼。
val phoneRegex = Regex("^\\d{4}-\\d{4}$")
if (phoneRegex.matches(phone)) {
this.phone = phone
}
}
fun printInfo() {
println(
"姓名:$name,年齡:$age," +
"Email:$email,電話:$phone"
)
}
}
fun main() {
// 呼叫主建構子。
val person1 = Info(
name = "Mary",
age = 20
)
person1.printInfo()
// 呼叫第一個次要建構子。
val person2 = Info(
name = "John",
age = 22,
email = "john123@mail.com"
)
person2.printInfo()
// 呼叫第二個次要建構子。
val person3 = Info(
name = "Nancy",
age = 21,
email = "nancy@mail.com",
phone = "2274-5678"
)
person3.printInfo()
// 電話格式不正確,會保留預設值 No Phone。
val person4 = Info(
name = "Jack",
age = 33,
email = "jack@gmail.com",
phone = "22222"
)
person4.printInfo()
}
執行結果:
姓名:Mary,年齡:20,Email:No Email,電話:No Phone
姓名:John,年齡:22,Email:john123@mail.com,電話:No Phone
姓名:Nancy,年齡:21,Email:nancy@mail.com,電話:2274-5678
姓名:Jack,年齡:33,Email:jack@gmail.com,電話:No Phone
類別也可以不宣告主建構子,改為在類別內使用一個或多個 constructor。不過,如果只是設定屬性的初始值,通常使用主建構子會更加精簡。參考資料:Kotlin Constructors
class CarInfo {
var brand: String
var model: String
var year: Int
var speed: Int
// 接收品牌、型號與年份的建構子。
constructor(
brand: String,
model: String,
year: Int
) {
this.brand = brand
this.model = model
this.year = year
this.speed = 0
}
// 沒有傳入年份時,呼叫上一個建構子,
// 並將年份設為 2026。
constructor(
brand: String,
model: String
) : this(
brand = brand,
model = model,
year = 2026
)
fun accelerate(increase: Int) {
speed += increase
}
fun printInfo() {
println("Brand:$brand")
println("Model:$model")
println("Year:$year")
println("Speed:$speed")
println("====================")
}
}
fun main() {
val car = CarInfo(
brand = "BMW",
model = "X3",
year = 2025
)
car.printInfo()
car.speed = 50
car.accelerate(20)
car.printInfo()
val newCar = CarInfo(
brand = "Toyota",
model = "RAV4"
)
newCar.printInfo()
}
Kotlin 的類別與成員預設都是不可繼承、不可覆寫的,也就是預設具有類似 Java final 的效果。若要讓其他類別繼承,父類別前面必須加上 open;要允許子類別改寫屬性或方法,也必須在該成員前面加上 open。參考資料:Kotlin Inheritance
open class ParentClass(
val name: String
) {
open fun show() {
println("Parent name:$name")
}
}
class ChildClass(
name: String,
val age: Int
) : ParentClass(name) {
override fun show() {
// 呼叫父類別原本的 show()。
super.show()
// 加入子類別自己的顯示內容。
println("Child name:$name,age:$age")
}
}
fun main() {
val child = ChildClass(
name = "John",
age = 20
)
child.show()
}
執行結果:
Parent name:John
Child name:John,age:20
子類別的建構子必須把父類別需要的資料傳給父類別。在 ChildClass 後面的 : ParentClass(name),就是呼叫父類別的建構子。參考資料:Kotlin Derived Class Initialization
override 表示子類別要覆寫父類別允許覆寫的屬性或方法;super 則可以存取父類別版本的屬性或方法。參考資料:Kotlin Overriding Methods
open class Game(
val player: String,
playerLevel: Int
) {
open var level: Int = playerLevel
open val score: Int = 60
open fun printInfo() {
println(
"Game Player:$player," +
"Level:$level,Score:$score"
)
}
}
open class ChildGame(
player: String,
playerScore: Int
) : Game(
player = player,
playerLevel = 10
) {
override var level: Int = 50
override val score: Int = playerScore
init {
// 取得父類別初始化後的 level,再加 1。
level = super.level + 1
}
override fun printInfo() {
// 先執行父類別版本。
super.printInfo()
// 再執行子類別新增的內容。
println(
"ChildGame Player:$player," +
"Level:$level,Score:$score"
)
}
}
class GrandchildGame(
player: String,
playerLevel: Int
) : ChildGame(
player = player,
playerScore = 100
) {
override var level: Int = playerLevel
override fun printInfo() {
super.printInfo()
println(
"GrandchildGame Player:$player," +
"Level:$level,Score:$score"
)
}
}
把子類別物件放進父類別型別的變數,稱為向上轉型(Upcasting)。因為子類別本來就是父類別的一種,所以 Kotlin 通常可以自動完成向上轉型,不需要額外使用 as。參考資料:Kotlin Type Checks and Casts
fun main() {
val child = ChildGame(
player = "Mary",
playerScore = 88
)
// 自動向上轉型,不必寫成 child as Game。
val game: Game = child
game.printInfo()
}
雖然 game 變數的型別是 Game,實際保存的物件仍然是 ChildGame,所以呼叫 printInfo() 時,執行的是 ChildGame 覆寫後的方法。這項行為正是多型的基礎。參考資料:Kotlin Inheritance
多型(Polymorphism)指的是使用相同的父類別型別操作不同子類別物件,而執行時會依物件的實際型別,自動呼叫正確的覆寫方法。參考資料:Kotlin Inheritance
fun printGameInfo(game: Game) {
// 實際執行哪一個 printInfo(),
// 由傳入物件的真正型別決定。
game.printInfo()
}
fun main() {
val game1: Game = Game(
player = "AAA",
playerLevel = 8
)
val game2: Game = ChildGame(
player = "BBB",
playerScore = 77
)
val game3: Game = GrandchildGame(
player = "CCC",
playerLevel = 2
)
printGameInfo(game1)
println()
printGameInfo(game2)
println()
printGameInfo(game3)
}
printGameInfo() 只宣告接收 Game,卻可以處理 Game、ChildGame 與 GrandchildGame。未來即使新增其他 Game 子類別,這個函式通常也不需要修改,這就是多型對程式擴充性的重要價值。參考資料:Kotlin Inheritance
當父類別變數可能保存不同子類別物件時,可以使用 is 判斷實際型別。判斷成功後,Kotlin 會進行智慧轉型(Smart Cast),因此不必再手動轉型。參考資料:Kotlin is and Smart Casts
open class Person(
val name: String
) {
fun show() {
println("姓名:$name")
}
}
class StudentPerson(
name: String,
val score: Int
) : Person(name) {
fun showScore() {
println("姓名:$name,成績:$score")
}
}
fun checkPerson(person: Person) {
when (person) {
// 判斷成功後,person 會智慧轉型成 StudentPerson。
is StudentPerson -> person.showScore()
// 其他 Person 物件執行一般顯示。
else -> person.show()
}
}
fun main() {
checkPerson(
Person(name = "Mary")
)
checkPerson(
StudentPerson(
name = "John",
score = 90
)
)
}
在 when 中應優先判斷範圍較小的子類別,再處理父類別或使用 else。否則,如果先判斷父類別,子類別物件也會符合父類別條件,後面的子類別分支便可能無法執行。參考資料:Kotlin Type Checks
接著以汽車為例建立父類別 Car,再讓燃油車與電動車繼承共同資料。品牌、型號及年份放在父類別,燃油里程與充電功能則分別放在子類別。參考資料:Kotlin Inheritance
open class Car(
val brand: String,
val model: String,
val year: Int
) {
// 加上 open,讓子類別可以依自身特性覆寫。
open fun displayInfo() {
println(
"汽車品牌:$brand,型號:$model,年份:$year"
)
}
}
class OilCar(
brand: String,
model: String,
year: Int
) : Car(
brand = brand,
model = model,
year = year
) {
override fun displayInfo() {
println(
"燃油車品牌:$brand,型號:$model,年份:$year"
)
}
fun calculateRange(
fuelCapacity: Int,
fuelEfficiency: Int
): Int {
return fuelCapacity * fuelEfficiency
}
}
class ElectricCar(
brand: String,
model: String,
year: Int,
initialBattery: Int
) : Car(
brand = brand,
model = model,
year = year
) {
// 電量限制在 0~100 之間。
var batteryCapacity: Int =
initialBattery.coerceIn(0, 100)
private set
override fun displayInfo() {
println(
"電動車品牌:$brand,型號:$model," +
"年份:$year,電量:$batteryCapacity%"
)
}
fun charge(amount: Int) {
// 充電後最高不能超過 100%。
batteryCapacity =
(batteryCapacity + amount).coerceAtMost(100)
println(
"$brand $model 已充電至 $batteryCapacity%"
)
}
}
fun main() {
val normalCar = Car(
brand = "Toyota",
model = "RAV4",
year = 2022
)
val oilCar = OilCar(
brand = "BMW",
model = "X3",
year = 2025
)
val electricCar = ElectricCar(
brand = "Tesla",
model = "Model 3",
year = 2026,
initialBattery = 50
)
normalCar.displayInfo()
println()
oilCar.displayInfo()
val drivingRange = oilCar.calculateRange(
fuelCapacity = 50,
fuelEfficiency = 15
)
println("預估可行駛 $drivingRange 公里")
println()
electricCar.displayInfo()
electricCar.charge(amount = 30)
electricCar.displayInfo()
println()
println("--- 使用多型顯示所有汽車 ---")
// 父類別集合可以同時保存父類別與不同子類別物件。
val carList: List<Car> = listOf(
normalCar,
oilCar,
electricCar
)
for (car in carList) {
car.displayInfo()
}
}
執行結果:
汽車品牌:Toyota,型號:RAV4,年份:2022
燃油車品牌:BMW,型號:X3,年份:2025
預估可行駛 750 公里
電動車品牌:Tesla,型號:Model 3,年份:2026,電量:50%
Tesla Model 3 已充電至 80%
電動車品牌:Tesla,型號:Model 3,年份:2026,電量:80%
--- 使用多型顯示所有汽車 ---
汽車品牌:Toyota,型號:RAV4,年份:2022
燃油車品牌:BMW,型號:X3,年份:2025
電動車品牌:Tesla,型號:Model 3,年份:2026,電量:80%
這裡的 carList 宣告為 List<Car>,卻能同時保存 Car、OilCar 與 ElectricCar。迴圈每次都呼叫相同的 displayInfo(),但實際顯示結果會依照物件型別改變,完整呈現多型的效果。參考資料:Kotlin Overriding Methods
Kotlin 官方程式碼風格建議類別名稱使用大駝峰式命名法(UpperCamelCase),例如 PlayerClass、NewPlayer、ElectricCar。函式、屬性及區域變數則使用小駝峰式命名法(lowerCamelCase),例如 displayInfo()、batteryCapacity、playerName。參考資料:Kotlin Coding Conventions
建議把原始練習中的 newPlayer2、newPlayer3 改成 NewPlayer2、NewPlayer3,並把 batteryCappacity 修正為 batteryCapacity。程式即使使用原名稱仍可能正常執行,但遵守一致的命名規則能讓程式更容易閱讀與維護。參考資料:Kotlin Coding Conventions
今天從最基本的類別與物件開始,逐步學會如何將資料宣告成屬性、將功能封裝成方法,以及如何透過 getter、setter 驗證或轉換資料。參考資料:Kotlin Classes、Kotlin Properties
主建構子適合用來簡潔地宣告必要資料,init 可以處理物件建立時的初始化工作,次要建構子則能提供不同的物件建立方式。參考資料:Kotlin Constructors
需要建立父子關係時,父類別及可覆寫成員必須使用 open,子類別使用 override 改寫行為,並能透過 super 呼叫父類別原本的實作。參考資料:Kotlin Inheritance
最後,透過向上轉型與多型,我們可以使用同一個父類別型別管理不同的子類別物件;搭配 is 與智慧轉型,還能安全判斷物件的真正型別。參考資料:Kotlin Type Checks and Casts
請建立一個 Employee 父類別,包含姓名與基本薪資,並宣告可覆寫的 calculateSalary() 方法。接著建立 FullTimeEmployee 與 PartTimeEmployee 子類別,讓兩者使用不同方式計算薪資。參考資料:Kotlin Inheritance
最後,把不同員工放進 List<Employee>,使用 for 迴圈依序呼叫 calculateSalary(),觀察同一個方法如何根據不同物件產生不同結果。這就是今天學到的多型實際應用。參考資料:Kotlin Collections Overview、Kotlin Inheritance
下一篇將繼續進入 Kotlin 物件導向的重要功能,包括抽象類別(Abstract Class)、介面(Interface),以及如何使用介面降低類別之間的相依性。參考資料:Kotlin Interfaces