PHP 基本语法 07|类与对象:从属性、构造到继承

最小可用的类

<?php
class User
{
    public string $name;
    private int $age = 0;

    public function __construct(string $name, int $age)
    {
        $this->name = $name;
        $this->age = $age;
    }

    public function greet(): string
    {
        return "你好,{$this->name}";
    }
}

$u = new User("张三", 28);
echo $u->greet();

三个必须记住的点:

  1. 属性必须声明可见性(public / protected / private)。var $x 是老写法,等价于 public,别再用。
  2. 类内部访问属性用 $this->name,不是 $name。$this-> 后面不带 $ 符号。
  3. 对象是引用语义。$b = $a 之后两者指向同一个实例。

属性类型声明与"未初始化"状态

PHP 7.4 起属性可以带类型:

<?php
class Order
{
    public int $id;               // 有类型,但没默认值
    public string $status = 'new'; // 有类型 + 默认值
    public ?string $note = null;   // 可空
}

带类型且没默认值的属性,处于"未初始化(uninitialized)"状态。读它会抛 Error,不是返回 null:

<?php
$o = new Order();
echo $o->status;    // new     有默认值,没问题
echo $o->id;        // ✗ Error: Typed property Order::$id must not be accessed before initialization

这个设计比"默认给 null"更安全,因为它逼你在构造函数里把所有必要字段都赋上。但它也意味着"声明类型就必须负责初始化",别漏。

用 unset($o->id) 可以让属性回到未初始化状态,用于"重置"语义。

构造函数与属性提升(PHP 8.0)

<?php
// 老写法:声明一遍,构造里再赋一遍
class PointOld
{
    public float $x;
    public float $y;

    public function __construct(float $x, float $y)
    {
        $this->x = $x;
        $this->y = $y;
    }
}

// 新写法:构造器属性提升
class Point
{
    public function __construct(
        public float $x,
        public float $y,
        private string $label = "点",
    ) {}
}

参数前加可见性修饰符,PHP 会自动声明属性并赋值。这是 PHP 8 里最能省代码的一处改动,尤其对 DTO、值对象、依赖注入的构造器。

限制:提升的只能是"简单"声明(不能有 int|string 之外的复杂默认值表达式逻辑),且提升属性会参与构造函数签名(所以命名参数调用会受影响)。

readonly:一次写入,永久不变(PHP 8.1)

<?php
class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency = "CNY",
    ) {}

    public function add(Money $other): Money
    {
        return new Money($this->amount + $other->amount, $this->currency);
    }
}

$m = new Money(100);
// $m->amount = 200;    // ✗ Error: Cannot modify readonly property

readonly 的规则:

  • 只能在声明它的类作用域内初始化一次(通常在构造函数里)。
  • 初始化之后任何写入(包括 ++、unset)都会抛 Error。
  • 不能再和 static 组合。

PHP 8.2 加了 readonly class,整个类所有属性都是 readonly:

<?php
readonly class Config
{
    public function __construct(
        public string $host,
        public int $port,
    ) {}
}

这组特性最实际的价值:让值对象真正不可变,从而在并发、缓存、函数传参时都不用担心被意外改掉。

可见性

<?php
class Base
{
    public    string $open = "公开";     // 任何地方可访问
    protected string $half = "受保护";   // 本类和子类
    private   string $secret = "私有";   // 仅本类
}

protected 的语义是"继承链内可见",private 则是"仅声明它的那个类"。一个常见的误解是子类能访问父类的 private —— 不能,即使子类里有个同名属性也是两回事。

静态成员与三种 self

<?php
class Registry
{
    private static array $data = [];       // 静态属性,属于类
    public const VERSION = "1.0";          // 类常量

    public static function set(string $k, mixed $v): void
    {
        self::$data[$k] = $v;              // self 指当前类
        static::$data[$k] = $v;            // static 指"运行时的类"(后期静态绑定)
    }

    public static function get(string $k): mixed
    {
        return static::$data[$k] ?? null;
    }
}

Registry::set("a", 1);
echo Registry::get("a");

三个关键字的区别:

关键字 指向
self 定义这个方法的那个类(编译期确定)
parent 父类
static 运行时实际调用的类(后期静态绑定)

差别在继承时才显现:

<?php
class A
{
    public static function who(): string { return self::class; }
    public static function whoLate(): string { return static::class; }
}

class B extends A {}

echo B::who();       // A   —— self 定死
echo B::whoLate();   // B   —— static 跟随调用方

写工厂方法、模板方法时,用 static:: 才能让子类拿到"自己"。

类常量

<?php
class Status
{
    public const PAID = 1;
    private const SECRET = "x";      // PHP 7.1 起常量也能有可见性
}

echo Status::PAID;

PHP 8.3 起常量可以带类型:

<?php
class Config
{
    public const string NAME = "app";   // 8.3+
}

常量在编译期就确定,不可修改,适合放状态码、配置键名。

继承

<?php
class Animal
{
    public function __construct(protected string $name) {}

    public function speak(): string
    {
        return "{$this->name} 叫了一声";
    }
}

class Cat extends Animal
{
    public function speak(): string
    {
        return parent::speak() . ":喵";    // 调父类实现
    }
}

要点:

  • extends 只能单继承。多继承靠接口和 trait(下一篇讲)。
  • 子类覆盖方法时,签名要兼容(参数类型不能更严格、返回类型不能更宽)。
  • parent::method() 显式调父类;parent::__construct() 处理父类初始化。
  • 子类继承父类的 public/protected 成员,private 不继承。

final 用来阻止继承或覆盖:

<?php
final class Immutable {}           // 不能被继承

class A
{
    final public function id(): int { return 1; }   // 子类不能覆盖
}

对象的比较:== 和 ===

<?php
$a = new User("张三", 28);
$b = new User("张三", 28);
$c = $a;

var_dump($a == $b);      // true   同类、属性值相同
var_dump($a === $b);     // false  不是同一个实例
var_dump($a === $c);     // true   同一个实例

== 比较属性值,=== 比较是否同一实例。这个语义和数组完全不同(数组的 == 是值比较、=== 也是值比较但要键类型一致),很容易混。

clone:浅拷贝

<?php
class Order
{
    public array $items = [];

    public function __clone()
    {
        // 深拷贝内部对象/数组
        $this->items = array_map(fn($i) => clone $i, $this->items);
    }
}

$a = new Order();
$a->items[] = (object)['sku' => 'A'];

$b = clone $a;
// 没有 __clone 时,$b->items 里的对象和 $a 是同一批

clone 是浅拷贝:值类型属性复制,对象属性仍共享引用。要让内部对象也独立,必须实现 __clone() 手工处理。

想"复制一份并改几个属性",PHP 没有一步到位的语法,标准做法是 clone 之后逐项赋值:

<?php
$b = clone $a;
$b->status = 'shipped';

要频繁做这件事时,更好的方式是提供一个 withXxx() 方法返回新实例(不可变对象的惯用法):

<?php
public function withStatus(string $s): static
{
    $new = clone $this;
    $new->status = $s;
    return $new;
}

匿名类

<?php
$logger = new class ("app.log") {
    public function __construct(private string $file) {}
    public function write(string $m): void
    {
        file_put_contents($this->file, $m . PHP_EOL, FILE_APPEND);
    }
};

$logger->write("hello");

匿名类适合测试替身、一次性小实现。它能继承、能实现接口、能写构造器,语法和普通类一样,只是没有名字。

动态属性:PHP 8.2 起被弃用

<?php
class User {}
$u = new User();
$u->nickname = "小张";    // PHP 8.2 起报 Deprecated: Creation of dynamic property

以前随手往对象上塞属性是常见做法,8.2 起会给出弃用警告。三种合法做法:

  1. 提前声明属性。
  2. 用 #[AllowDynamicProperties] 标注(不推荐,只是压掉警告)。
  3. 实现 __get/__set 魔术方法(下一篇讲)。

这个变更对老项目影响很大,升级到 8.2 时日志会被这条警告刷屏——但它是好事,动态属性是很多"字段名拼错、静默为 null"问题的根源。

对象与 null 安全

<?php
$user = getUser();               // 可能返回 null
echo $user?->name ?? "匿名";      // PHP 8.0 的 nullsafe

比 $user && $user->name ? ... : ... 干净得多,链式调用时收益更明显:

<?php
echo $order?->customer?->address?->city ?? "未填写";

坑清单

  1. 带类型属性不初始化就访问,抛 Error 不是 null。
  2. $this-> 后面不带 $,写成 $this->$name 是动态属性名。
  3. self:: 在继承时不会跟着变,要跟随就用 static::。
  4. 子类 private 和父类 private 同名时是两个属性。
  5. clone 是浅拷贝,内部对象仍共享。
  6. == 比较对象是"属性值相等",容易误以为在比较同一对象。
  7. 8.2 起动态属性被弃用,老代码升级要清理。
  8. 只读属性(readonly)在构造函数之外赋值会抛错。