Chuyển đến nội dung chính (Skip to content)
Mục lục bài viết
PHP

PHP EP 11: Class, Object và Constructor Promotion trong PHP

PHP EP 11: Class, Object và Constructor Promotion trong PHP

Xây dựng lớp và đối tượng trong PHP với thuộc tính có kiểu, Constructor Property Promotion và readonly, thực hành gọi phương thức trực tiếp ngay khi khởi tạo.

Đối tượng có kiểu giúp kiểm soát trạng thái

Ở các bài học trước, chúng ta dùng mảng kết hợp như ['host' => 'web-01', 'cpu' => 88] để biểu diễn dữ liệu nhanh gọn. Tuy nhiên, khi dự án mở rộng quy mô, việc truyền mảng tự do qua nhiều tầng hàm sẽ bộc lộ ba điểm yếu lớn: trình thông dịch không phát hiện được lỗi gõ nhầm tên khóa 'cpu' thành 'cup', không thể kiểm tra kiểu dữ liệu của từng trường ngay tại chữ ký hàm, và không gắn kèm được quy tắc bảo toàn tính hợp lệ ngay lúc khởi tạo. Hậu quả là mọi hàm nhận mảng đều phải viết lại hàng loạt lệnh isset() phòng thủ.

Bài học mở đầu Chặng 3 này hướng dẫn bạn chuyển từ mảng tự do sang mô hình Lập trình hướng đối tượng (OOP) hiện đại trong PHP 8.4 với class, thuộc tính định kiểu (typed properties), cú pháp rút gọn Constructor Property Promotion, từ khóa bất biến readonly và cú pháp khởi tạo chuỗi không cần ngoặc phụ new MyClass()->method() mới trên PHP 8.4. Bài viết kế thừa kiến thức từ PHP EP 10: Mảng PHP và các hàm array_find, array_any, array_all và làm tiền đề cho PHP EP 12: Property Hooks và Asymmetric Visibility trong PHP 8.4. Tài liệu tham chiếu gồm PHP Manual: Classes and Objects, Readonly classes và PHP RFC: new MyClass()->method() without parentheses.

Hồ sơ kiểm chứng & Môi trường Lab (Verification Profile):

  • Ngày kiểm chứng: 2026-10-26.
  • Môi trường thực thi: Container cô lập php-course-qa:8.4 (--network none, --read-only, --tmpfs /tmp:rw,exec,nosuid,size=64m, user phplab UID 1001).
  • Phiên bản runtime: PHP 8.4.27 (cli) trên Alpine Linux 3.21 (aarch64), Zend Engine v4.4.27.
  • Giới hạn kiểm chứng: Kiểm chứng vòng đời khởi tạo đối tượng, trạng thái uninitialized của thuộc tính định kiểu, lớp readonly và cú pháp new ...()->... của PHP 8.4; chưa đi vào Property Hooks (EP 12) hay Autoloading PSR-4 (EP 14).

Hợp đồng của thuộc tính, khởi tạo và readonly

Để thiết kế lớp gọn gàng và chặt chẽ, bạn cần nắm rõ sự khác biệt giữa thuộc tính định kiểu, Constructor Promotion, readonly và quy tắc khởi tạo của PHP 8.4:

Cơ chế OOP Cú pháp tiêu biểu Hành vi thực tế trong Zend Engine Điều kiện / Không nên giả định
Thuộc tính định kiểu (Typed Property) public int $weight; Khi chưa được gán giá trị, thuộc tính mang trạng thái nội bộ IS_UNDEF (uninitialized), không phải null. Đọc thuộc tính định kiểu trước khi gán giá trị sẽ ném Error: Typed property ... must not be accessed before initialization.
Constructor Property Promotion public function __construct(public string $id) {} Trình biên dịch tự động khai báo thuộc tính $this->id và gán giá trị tham số ngay đầu hàm __construct(). Không dùng được với kiểu callable; vẫn có thể viết thêm logic kiểm tra trong thân __construct().
Thuộc tính / Lớp readonly readonly class Invoice { ... } Mọi thuộc tính chỉ được phép gán đúng 1 lần duy nhất; mọi nỗ lực ghi đè sau đó đều ném Error. readonly class bắt buộc 100% thuộc tính phải có khai báo kiểu tường minh và cấm tạo thuộc tính động (dynamic properties).
Khởi tạo gọi chuỗi trực tiếp (PHP 8.4) new ReleaseTag('api', 42)->label() Truy cập phương thức, thuộc tính hoặc hằng số ngay sau new ClassName(...) mà không cần bọc ngoặc đơn (new ...). Phải luôn có cặp ngoặc tròn () sau tên lớp (new Foo()->bar() hợp lệ, nhưng new Foo->bar() là lỗi cú pháp).

Typed Properties và Constructor Promotion hoạt động ra sao

Khi một lớp định nghĩa thuộc tính có kiểu tường minh (public int $weight;) mà không có giá trị mặc định, Zend Engine không tự gán null mà đánh dấu ô nhớ bằng cờ IS_UNDEF (uninitialized). Thiết kế này bảo toàn lời hứa kiểu dữ liệu: nếu thuộc tính khai báo là int (không phải ?int), bất kỳ lúc nào đọc thành công thuộc tính đó, kết quả chắc chắn là một số nguyên int. Để tránh quên khởi tạo, Constructor Property Promotion (PHP Manual) gộp bước khai báo thuộc tính, tham số hàm tạo và lệnh gán $this->prop = $prop vào ngay danh sách tham số của __construct().

Khi kết hợp Constructor Promotion với readonly class, toàn bộ thuộc tính được khóa cứng chỉ-ghi-một-lần (write-once) ngay sau khi khởi tạo, rất lý tưởng cho đối tượng truyền dữ liệu (DTO) và đối tượng giá trị (Value Object).

Đặc biệt trên PHP 8.4, đặc tả PHP RFC: new MyClass()->method() without parentheses cho phép gọi chuỗi phương thức hoặc đọc thuộc tính trực tiếp ngay sau new ClassName($args)->method(), loại bỏ cặp ngoặc đơn dư thừa (new ClassName($args))->method() của các phiên bản cũ.

Vòng đời khởi tạo đối tượng từ cấp phát thuộc tính có kiểu đến thực thi __construct.
Hình 1. Vòng đời khởi tạo đối tượng từ cấp phát thuộc tính có kiểu đến thực thi __construct. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Tạo Value Object và kiểm tra cách khởi tạo

Chúng ta sẽ xây dựng lớp readonly class ReleaseTag sử dụng Constructor Property Promotion, kiểm tra điều kiện hợp lệ trong hàm tạo và gọi phương thức trực tiếp bằng cú pháp new ReleaseTag(...)->label() của PHP 8.4.

Bước 1: Tạo vùng làm việc tạm an toàn

Mục đích: Khởi tạo thư mục thực hành tách biệt trong /tmp.

WORKDIR="$(mktemp -d /tmp/php-ep11-XXXXXX)"
cd "$WORKDIR"
pwd

Bước 2: Tạo kịch bản release_tag.php với readonly class và cú pháp new ...()->... của PHP 8.4

Mục đích: Định nghĩa đối tượng giá trị bất biến biểu diễn thẻ phát hành phần mềm và gọi phương thức label() ngay trên biểu thức new.

cat <<'EOF' > "$WORKDIR/release_tag.php"
<?php
declare(strict_types=1);

readonly class ReleaseTag
{
    public function __construct(
        public string $service,
        public int $buildNumber,
    ) {
        if ($this->buildNumber <= 0) {
            throw new InvalidArgumentException('Build number must be positive.');
        }
    }

    public function label(): string
    {
        return "{$this->service}#b{$this->buildNumber}";
    }
}

echo "Label=" . new ReleaseTag('billing-api', 42)->label() . "\n";
EOF
php "$WORKDIR/release_tag.php"

Kết quả quan sát được:

  • Đầu ra hiển thị: Label=billing-api#b42.
  • Trình biên dịch PHP 8.4 tự động thực hiện phép gán $this->service và $this->buildNumber trước khi chạy thân hàm { ... } của __construct(), nên câu lệnh if ($this->buildNumber <= 0) kiểm tra được ngay giá trị vừa truyền vào.

Cảnh báo an toàn: Trong một readonly class, nếu thuộc tính giữ một đối tượng khả biến (như DateTime), từ khóa readonly chỉ ngăn gán đối tượng mới đè lên thuộc tính đó chứ không ngăn sửa nội dung bên trong đối tượng con. Hãy luôn dùng lớp bất biến như DateTimeImmutable.

Thay đổi trạng thái bằng cách trả về bản sao

Khi đối tượng đã khóa bằng readonly, muốn tạo phiên bản cập nhật (ví dụ tăng số thứ tự bản dựng thêm 1) mà không phá vỡ tính bất biến, chúng ta cung cấp phương thức with*() trả về một thực thể mới.

Bước 3: Tạo kịch bản immutable_step.php minh họa phương thức withNextRevision()

Mục đích: Giữ nguyên thực thể gốc $v1 và tạo ra thực thể mới $v2 với số thứ tự bản dựng tăng thêm 1.

cat <<'EOF' > "$WORKDIR/immutable_step.php"
<?php
declare(strict_types=1);

readonly class BuildArtifact
{
    public function __construct(
        public string $name,
        public int $revision,
    ) {}

    public function withNextRevision(): self
    {
        return new self($this->name, $this->revision + 1);
    }
}

$v1 = new BuildArtifact('core-worker', 10);
$v2 = $v1->withNextRevision();

echo "V1={$v1->revision} | V2={$v2->revision} | SameObject=" . ($v1 === $v2 ? 'true' : 'false') . "\n";
EOF
php "$WORKDIR/immutable_step.php"

Kết quả quan sát được:

  • Màn hình in ra: V1=10 | V2=11 | SameObject=false.
  • Toán tử === trên hai biến đối tượng chỉ trả về true khi cả hai cùng trỏ tới đúng một thực thể trong bộ nhớ. Ở đây $v1 và $v2 độc lập hoàn toàn.

Cảnh báo an toàn: Không dùng thuộc tính static để lưu trạng thái thay đổi theo từng request của người dùng vì dễ gây rò rỉ dữ liệu giữa các tác vụ trên môi trường worker chạy dài hạn.

Kiểm tra thuộc tính chưa khởi tạo và dọn dẹp

Nếu khai báo public int $weight; bên trong lớp nhưng quên gán giá trị trước khi đọc, Zend Engine sẽ chặn lại bằng lỗi nghiêm trọng thay vì âm thầm trả về null.

Bước 4: Đọc thuộc tính định kiểu trước khi khởi tạo và kiểm tra mã thoát

Mục đích: Xác nhận PHP 8.4 ném Error khi đọc thuộc tính đang ở trạng thái uninitialized.

set +e
php -r '
class Box { public int $weight; }
$b = new Box();
echo $b->weight;
'
RC=$?
set -e
echo "RC=$RC"

Kết quả quan sát được:

  • Tiến trình kết thúc với RC=255 kèm thông báo: Fatal error: Uncaught Error: Typed property Box::$weight must not be accessed before initialization.
  • Ngay cả khi khai báo public ?int $weight; (có dấu ?) nhưng không viết = null, thuộc tính lúc mới tạo vẫn ở trạng thái uninitialized chứ chưa phải null.
Ranh giới giữa thuộc tính chưa khởi tạo (uninitialized), thuộc tính thường và readonly.
Hình 2. Ranh giới giữa thuộc tính chưa khởi tạo (uninitialized), thuộc tính thường và readonly. Sơ đồ minh họa khái niệm; không phải ảnh chụp hay bản ghi kiểm thử. Mở hình để xem chi tiết.

Bước 5: Dọn dẹp vùng làm việc tạm

Mục đích: Xóa thư mục thực hành trong /tmp.

rm -rf "$WORKDIR"

Giới hạn bất biến của đối tượng trên production

Trong kiến trúc thực tế, cần phân loại đối tượng theo vai trò:

  1. Value Object & DTO: Dùng cho dữ liệu request, cấu hình hoặc kết quả trả về giữa các tầng. Hãy luôn khai báo readonly class kết hợp Constructor Property Promotion để khóa trạng thái.
  2. Domain Entity có trạng thái: Các thực thể cần thay đổi trạng thái theo vòng đời nghiệp vụ (như tài khoản tăng số lần đăng nhập sai) không thể khóa cứng bằng readonly class, mà cần kết hợp Property Hooks và Asymmetric Visibility (public private(set)) ở bài học kế tiếp.

Tự kiểm tra và giới hạn

Hãy tự trả lời 4 câu hỏi tình huống dưới đây:

  • Câu hỏi 1: Khai báo class Order { public ?string $note; } (không có = null) thì ngay sau $o = new Order(), biểu thức $o->note === null trả về true hay gây lỗi? -> Gây lỗi Error (Uninitialized property). Bạn phải viết public ?string $note = null; nếu muốn nó mặc định mang giá trị null.
  • Câu hỏi 2: Trong PHP 8.4, cú pháp new ReleaseTag('api', 1)->label() có cần bọc thêm ngoặc đơn (new ...)->label() không? -> Không cần. PHP 8.4 hỗ trợ gọi chuỗi trực tiếp sau new ClassName(...).
  • Câu hỏi 3: Trong __construct(public int $age) { ... }, thân hàm { ... } chạy trước hay sau khi thuộc tính $this->age được gán giá trị? -> Chạy sau. Phép gán được trình biên dịch tự động chèn vào đầu hàm tạo.
  • Câu hỏi 4: Một readonly class có cho phép thêm thuộc tính động ($obj->extra = 1) lúc chạy không? -> Không. Nó cấm hoàn toàn thuộc tính động và ném Error ngay lập tức.

Bạn đã nắm vững cách đóng gói dữ liệu bằng Class, Constructor Promotion và readonly trong PHP 8.4. Hãy tiếp tục sang PHP EP 12: Property Hooks và Asymmetric Visibility trong PHP 8.4 để làm chủ hai tính năng hướng đối tượng đột phá nhất của PHP 8.4.

Ôn bài trước: PHP EP 10: Mảng PHP và các hàm array_find, array_any, array_all. Học tiếp: PHP EP 12: Property Hooks và Asymmetric Visibility trong PHP 8.4.

Bài liên quan trong bộ PHP

Tìm hiểu thêm