Learning Objectives
- 定義類別、用 new 建立物件,說明屬性、方法與 $this 的關係,並用建構子(含屬性提升)設定初始值
- 用 public、protected、private 控制可見度,透過方法封裝資料與檢查規則
- 說明物件是 handle:指派與傳入函式不會複製物件(和陣列不同),需要複本時用 clone
- 使用 static 屬性與方法、類別常數,分辨 self 與 static
- 用 extends 繼承與覆寫方法,用 abstract 類別與 interface 規定子類別要做什麼,寫出多型的程式
- 認識 trait、__toString、readonly 屬性與 enum
把資料和操作它的函式放在一起
到目前為止,資料放在陣列裡,處理資料的是一個個獨立的函式:
$account = ['owner' => 'Amy', 'balance' => 0];
$account = deposit($account, 500); // 誰都可以直接寫 $account['balance'] = 1000000;
程式變大之後,「哪些函式可以處理這種陣列」「哪些欄位可以直接改」都只能靠大家自己記得。 物件導向(object-oriented programming,OOP)把資料和操作這些資料的函式包在一起,變成一個類別(class):
| 名詞 | 意思 | 例子 |
|---|---|---|
| 類別(class) | 藍圖:規定一種東西有哪些資料、能做哪些事 | BankAccount |
| 物件(object) | 照著藍圖做出來的一個個實體,也叫實例(instance) | Amy 的帳戶、Ken 的帳戶 |
| 屬性(property) | 物件裡的資料 | $owner、$balance |
| 方法(method) | 類別裡的函式,操作這個物件的資料 | deposit()、getBalance() |
你其實已經用過物件了:第 10 課的 DateTimeImmutable、第 13 課的例外都是。這一課學怎麼自己寫類別。
現代的 PHP 框架(Laravel、Symfony)與套件幾乎全部以類別組成,看懂物件導向是讀懂它們的前提。
類別、物件與 $this
class Student // 類別名稱習慣用大寫開頭(PascalCase)
{
public string $name; // 屬性:可以宣告型別,也可以給預設值
public array $scores = [];
public function __construct(string $name) // 建構子:new 的時候自動執行
{
$this->name = $name;
}
public function addScore(int $score): void // 方法
{
$this->scores[] = $score;
}
}
$amy = new Student('Amy'); // 建立物件,'Amy' 傳給建構子
$amy->addScore(90); // 呼叫方法:物件->方法()
echo $amy->name; // 讀取屬性:物件->屬性(屬性名稱前面不加 $)
方法裡的 $this 代表「呼叫這個方法的那個物件」:$amy->addScore(90) 執行時,$this 就是 $amy;
$ken->addScore(70) 執行時,$this 就是 $ken。同一段方法的程式,作用在不同的物件上。
建立物件與 $this
兩次 new Student(…) 在右邊做出兩個物件。進入方法時,看框架裡的 $this 箭頭指向哪一個物件:第 26、27 行呼叫 $amy 的方法,$this 指向 Amy;第 28 行換成 Ken。
建構子屬性提升與可見度
建構子常常只是把參數一個個存進屬性。PHP 8.0 起可以在建構子的參數前面加上可見度,PHP 會自動宣告屬性並指派,叫做建構子屬性提升(constructor property promotion):
// 傳統寫法 // PHP 8.0 的屬性提升,效果相同
class Point { class Point {
public int $x; public function __construct(
public int $y; public int $x = 0,
public function __construct(int $x = 0, public int $y = 0,
int $y = 0) { ) {}
$this->x = $x; }
$this->y = $y;
}
}
可見度決定誰可以存取屬性與方法:
| 關鍵字 | 類別自己 | 子類別 | 類別外面 | 物件圖上的標記 |
|---|---|---|---|---|
public |
✓ | ✓ | ✓ | (無) |
protected |
✓ | ✓ | ✗ | # |
private |
✓ | ✗ | ✗ | - |
從外面存取 private 屬性會丟出 Error: Cannot access private property …。
封裝(encapsulation)的作法:屬性設成 private,外面只能透過 public 方法讀寫,方法負責檢查規則(存款不能是負數、Email 要合法)。
這樣不論程式有多少地方用到這個類別,資料都不會被改成不合理的值。讀取用的方法叫 getter(getBalance()),修改用的叫 setter(setEmail());
只有真的需要被修改的屬性才提供 setter。
型別屬性(PHP 7.4):宣告了型別、又沒有預設值的屬性,在指派之前是「未初始化」,讀取它會丟出
Error: Typed property Student::$name must not be accessed before initialization。所以要在建構子裡設定,或給預設值。
readonly 屬性(PHP 8.1):只能在類別內部指派一次,之後誰都不能修改,適合帳號、建立時間這類不該變的資料。
修改時丟出 Error: Cannot modify readonly property …。
static 與類別常數
一般的屬性每個物件各有一份;static 屬性屬於類別本身,全部物件共用一份,用 類別名稱::$屬性 或在類別裡用 self::$屬性 存取。
static 方法不需要建立物件就能呼叫(BankAccount::count()),裡面沒有 $this。
類別常數用 const 宣告,不能修改,用 類別名稱::常數 讀取。
| 寫法 | 屬於 | 類別裡怎麼用 | 外面怎麼用 |
|---|---|---|---|
private int $balance |
每個物件 | $this->balance |
$a->balance(public 時) |
private static int $count |
類別 | self::$count |
BankAccount::$count(public 時) |
public const MIN_DEPOSIT = 100 |
類別 | self::MIN_DEPOSIT |
BankAccount::MIN_DEPOSIT |
在下面的動畫裡,static 屬性畫在「靜態屬性」框。
封裝、readonly 與 static
$balance 是 private,物件圖上前面有 -;$owner 用屬性提升宣告成 public readonly。看「靜態屬性」框裡的 BankAccount::$count:每建立一個物件就加 1,兩個物件共用這一個數字。第 29~31 行三次嘗試違反規則,都被擋下來了。
物件是 handle:指派不會複製
第 08 課說過:陣列是值,$b = $a 會複製一份。物件不一樣:變數裡存的是一個指向物件的 handle(可以想成門牌號碼),
$b = $a 只複製了門牌號碼,兩個變數指向同一個物件,透過任何一個修改,另一個都看得到。把物件傳給函式也一樣,函式可以修改到原本的物件。
| 陣列 | 物件 | |
|---|---|---|
$b = $a |
複製一份,各改各的 | 指向同一個物件 |
| 傳給函式 | 函式拿到複本 | 函式拿到同一個物件 |
| 要一份獨立的複本 | 指派就是複本 | $b = clone $a; |
== |
鍵與值都相同 | 同一個類別、屬性值都相同 |
=== |
鍵、值、型別、順序都相同 | 是同一個物件 |
clone 是淺複製(shallow copy):新物件的屬性是原本屬性的複本,但如果某個屬性本身是物件,複製的只是 handle,
兩個物件會共用那個內層物件。需要連內層一起複製時,在類別裡寫 __clone(),clone 完成後會自動呼叫它:
class Post
{
public Tag $tag;
public function __clone()
{
$this->tag = clone $this->tag; // 深複製:內層物件也複製一份
}
}
物件共用 vs 陣列複製
先看陣列:第 9 行執行完,右邊出現兩個陣列。再看物件:第 15 行執行完,$box1 和 $box2 的箭頭指向同一個 Box。第 20 行的 clone 才真的做出第二個 Box。
想一想:物件、可見度與 clone
下面的程式會印出什麼?
<?php
class Counter { public int $n = 0; }
function bump(Counter $c) { $c->n++; }
function bumpArr(array $a) { $a['n']++; }
$c = new Counter();
$arr = ['n' => 0];
bump($c);
bumpArr($arr);
echo $c->n, ' ', $arr['n'];
下面的程式執行的結果是什麼?
<?php
class User {
private string $password = 'secret';
}
$u = new User();
echo $u->password;
下面的程式會印出什麼?
<?php
class Tag { public string $name = 'php'; }
class Post {
public Tag $tag;
public array $likes = [];
public function __construct() { $this->tag = new Tag(); }
}
$a = new Post();
$b = clone $a;
$b->tag->name = 'js';
$b->likes[] = 'Amy';
echo $a->tag->name, ' ', count($a->likes);
繼承與覆寫
繼承(inheritance):class Dog extends Animal 讓 Dog 擁有 Animal 所有的屬性與方法,再加上自己的東西。
Animal 是父類別(parent class),Dog 是子類別(child class)。PHP 一個類別只能 extends 一個父類別。
- 覆寫(override):子類別寫一個同名的方法,取代父類別的版本
parent::方法():在子類別裡呼叫父類別原本的版本,常用在「先做父類別的事,再多做一點」,以及建構子parent::__construct(…)- 父類別的方法裡呼叫
$this->speak(),執行的是物件實際所屬類別的版本:Dog 物件就執行 Dog 的 speak。這是多型的基礎 protected的屬性子類別可以用,類別外面不行;private連子類別都不能直接用final:final class不能被繼承,final public function不能被覆寫instanceof:$x instanceof Animal檢查物件是不是這個類別(或它的子類別、它實作的介面)
「什麼時候該用繼承」的判斷:子類別是一種父類別(Dog 是一種 Animal)才用繼承;只是想共用幾個方法,用後面介紹的 trait 或把物件當成屬性(組合)比較合適。
繼承、覆寫與 parent::
三隻寵物都呼叫同一個 intro()(寫在 Animal 裡),但第 9 行的 $this->speak() 會依物件實際的類別,分別執行 Dog、Cat、Animal 的 speak。注意框架名稱:Animal::intro 裡呼叫到 Dog::speak。
abstract 類別與 interface
抽象類別(abstract class):只能被繼承、不能直接 new(會丟出 Error: Cannot instantiate abstract class Shape)。
裡面可以有寫好的方法,也可以有只宣告、不寫內容的抽象方法,子類別一定要實作,否則子類別也只能是抽象的。
介面(interface):只列出「一定要有哪些 public 方法」,不寫任何實作。類別用 implements 承諾會實作這些方法,
一個類別可以實作多個介面(用逗號分隔),這是和 extends 最大的不同。
interface HasArea {
public function area(): float;
}
abstract class Shape implements HasArea, Stringable { // 一次實作兩個介面
abstract public function name(): string; // 留給子類別
public function __toString(): string { … } // 共同的部分寫在這裡
}
| abstract class | interface | |
|---|---|---|
| 能不能 new | 不能 | 不能 |
| 有沒有寫好的方法 | 可以有 | 沒有 |
| 屬性 | 可以有 | 不行(可以有常數) |
| 一個類別可以有幾個 | extends 一個 |
implements 多個 |
多型(polymorphism):程式只依賴介面(「有 area() 方法的東西」),不管實際是圓形還是長方形;
之後新增三角形,只要它實作 HasArea,計算總面積的程式完全不用改。參數型別也可以寫介面:function total(HasArea ...$shapes)。
__toString() 是魔術方法:物件被當成字串使用(echo $s、字串串接)時自動呼叫。PHP 8.0 起,有 __toString 的類別會自動實作 Stringable 介面。
介面、抽象類別與多型
迴圈裡的 $s 有時是圓、有時是長方形,程式寫的都是同一句 $s->area()。echo $s 會自動呼叫 __toString();它寫在抽象類別 Shape 裡,再呼叫子類別實作的 name() 和 area()。
想一想:繼承與 static
下面的程式會印出什麼?
<?php
class A {
public function hi() { return 'A'; }
public function run() { return $this->hi(); }
}
class B extends A {
public function hi() { return 'B' . parent::hi(); }
}
echo (new B)->run();
下面的程式會印出什麼?
<?php
class Model {
public static function create(): static { return new static(); }
public static function who(): string { return self::class . '/' . static::class; }
}
class User extends Model {}
echo User::who(), ' ', get_class(User::create());
self 與 static
在類別裡指「類別本身」有兩種寫法,差別只在有繼承的時候:
self::指寫這段程式碼的那個類別,在寫的時候就決定了static::指實際被呼叫的那個類別,執行時才決定(late static binding,延遲靜態綁定)
上一題的 Model::create() 寫成 new static(),子類別 User::create() 就會建立 User;寫成 new self() 則永遠建立 Model。
框架裡「User::find(1) 回傳 User 物件」這類設計就是靠 static。一般情況用 self 即可。
trait 與 enum
trait:一組可以「貼進」類別的屬性與方法。PHP 只能單一繼承,幾個不相關的類別(Post、Comment、User)都需要相同的功能時,
寫成 trait,再在類別裡 use 它,效果就像把程式碼複製進去。一個類別可以 use 多個 trait。
trait HasTimestamps {
private string $createdAt = '';
public function touch(string $time): void { $this->createdAt = $time; }
}
class Post {
use HasTimestamps; // Post 物件現在有 touch() 方法
}
enum(列舉,PHP 8.1):一個型別只能是幾個固定值之一,例如文章狀態、訂單狀態、撲克牌花色。比起用字串 'draft' 表示,
打錯字('darft')時 PHP 會直接報錯,參數型別也可以寫成 Status $s。
enum Suit { // 純 enum(pure enum):只有名字
case Hearts;
case Spades;
}
enum Status: string { // backed enum:每個 case 對應一個字串或整數
case Draft = 'draft';
case Published = 'published';
}
| 寫法 | 結果 |
|---|---|
Status::Draft |
一個 enum 值;用 === 比較 |
Status::Draft->name |
'Draft'(case 的名字) |
Status::Draft->value |
'draft'(backed enum 才有) |
Status::cases() |
所有 case 組成的陣列 |
Status::from('published') |
從值找出 case;找不到丟出 ValueError |
Status::tryFrom('deleted') |
從值找出 case;找不到回傳 null |
enum 也可以有方法,常搭配 match ($this) 為每個 case 回傳不同的結果。資料庫裡存的是 ->value,讀出來再用 from() 變回 enum。
trait 與 enum
Post 透過 use HasTimestamps 得到 touch() 與 createdAt(),框架名稱顯示成 Post::touch。$status 屬性的型別是 enum Status,第 29 行用資料庫常見的字串值 published 換回 enum。
想一想:enum
下面的程式會印出什麼?
<?php
enum Size: int {
case S = 1;
case M = 2;
case L = 3;
}
$s = Size::tryFrom(4) ?? Size::M;
echo $s->name, $s->value, count(Size::cases());
動手做:購物車類別
完成 Cart 類別:add() 在數量小於 1 時丟出 InvalidArgumentException('數量至少是 1'),否則把商品記進 private 的 $items;total() 回傳所有商品「單價 × 數量」的總和。下面的主程式不用改。
重點回顧
- 類別是藍圖、物件是實體:
new 類別(…)建立物件、$物件->方法()呼叫、$物件->屬性讀寫;方法裡的$this是呼叫它的那個物件 __construct在 new 時執行;PHP 8.0 的屬性提升public function __construct(private int $x)一次完成宣告與指派- 可見度:
public誰都可以、protected自己與子類別、private只有自己;從外面存取 private 丟出 Error。用方法封裝資料與檢查規則 - 型別屬性在指派前是未初始化,讀取會丟出 Error;
readonly(PHP 8.1)只能指派一次 - 物件是 handle:指派、傳入函式都指向同一個物件(陣列則會複製);
clone做出淺複製,需要深複製時寫__clone() static屬性與方法屬於類別,用類別::或self::存取;const是類別常數;static::指實際被呼叫的類別extends繼承、同名方法覆寫、parent::呼叫父類別版本、final禁止繼承或覆寫、instanceof檢查類別abstract類別不能 new,抽象方法由子類別實作;interface只規定方法,一個類別可以implements多個;依賴介面寫程式就是多型trait用use把方法貼進類別;__toString讓物件可以被 echoenum(PHP 8.1):純 enum 與 backed enum,->name、->value、cases()、from()(找不到丟 ValueError)、tryFrom()(找不到回傳 null)