登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  php教程

PHP 非对称属性可见性如何限制对象外部写入

来源:17golang原创

时间:2026-10-09 05:25:19 216浏览 收藏

PHP 8.4 开始,属性可以拥有不同的读取和写入可见性。最常用的写法是 public private(set):对象外部可以直接读取属性,但只有声明该属性的类能够写入。若希望子类也能修改,则使用 public protected(set)。这解决了一个很实际的问题——公开展示数据不等于公开修改数据。

我第一次把这一语法放进 DTO 与领域对象之间的边界时,最明显的变化不是少写了几个 setter,而是代码审查终于能一眼看出“谁拥有状态变化权”。不过它也不是把 public 换成 private(set) 就结束了:构造、继承、数组元素写入、引用、序列化器和 ORM 都可能受到影响。

把读权限和写权限拆开理解

普通 public 属性允许任意作用域读取和写入;普通 private 属性则把两者都限制在声明类内。非对称可见性在中间提供了更精确的组合:主可见性控制读取,set 可见性单独控制写入,而且写入权限不能比读取权限更宽。

声明对象外读取对象外写入子类写入
public string $name允许允许允许
public private(set) string $name允许禁止禁止
public protected(set) string $name允许禁止允许
private string $name禁止禁止禁止直接访问

需要注意三条语法边界:只有带类型的属性才能单独声明 set 可见性;private(set) 中不能加入空格;当读取可见性为 public 时,可以省略前面的 public,因此 private(set) string $name 与完整写法等价。

PHP 非对称属性可见性的读取区、写入区与继承区静态关系图
图1:读取可见性与写入可见性是两条独立边界;外部调用方可以读取公开属性,却不能跨过 private(set) 或 protected(set) 的写入边界。这是原创静态结构图。

用 private(set) 收回对象外部赋值

下面的账户对象允许模板或响应转换器读取邮箱和状态,但外部代码不能直接改值。构造方法与领域方法位于类作用域内,因此仍然可以完成初始化和受控修改。

assertEmail($email);
    }

    public function changeEmail(string $newEmail): void
    {
        // 写入口集中在领域方法,校验和状态变化可以一起审查
        $this->assertEmail($newEmail);
        $this->email = $newEmail;
    }

    public function activate(): void
    {
        // 只允许对象自身把状态推进到 active
        $this->status = 'active';
    }

    private function assertEmail(string $email): void
    {
        // 示例只展示边界;生产项目可替换为统一的值对象或验证器
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            throw new InvalidArgumentException('Invalid email');
        }
    }
}

$account = new Account('dev@example.com');
echo $account->email;              // 允许:public 读取
$account->changeEmail('ops@example.com'); // 允许:受控写入

// 禁止:全局作用域不能直接修改 private(set) 属性
// $account->email = 'other@example.com';
?>

这段代码的价值在于把不变量放回对象内部。非对称可见性本身不会校验邮箱、不会记录审计日志,也不会限制状态机跳转;它只负责阻止不被允许的作用域直接写属性。真正的业务规则仍应由 changeEmail()、activate() 等方法承担。

把每一种状态变化集中到可审计入口

在生产项目里,我更倾向于把“能写”进一步收缩成“只能通过少数命名方法写”。这样权限、校验、事件与日志都有固定挂载点。下面的订单对象让外部读取状态,但所有状态变化都经过方法。

status !== 'created') {
            throw new LogicException('Only created orders can be paid');
        }

        $this->status = 'paid';
    }

    public function cancel(): void
    {
        // 已支付订单不能被普通取消流程回退
        if ($this->status === 'paid') {
            throw new LogicException('Paid order cannot be cancelled');
        }

        $this->status = 'cancelled';
    }
}
?>

如果项目需要写审计记录,最稳妥的位置就是这些领域方法,而不是依赖调用方“记得写日志”。同理,事件发布、权限判断和幂等检查也应靠近状态变化入口。private(set) 把绕过这些入口的普通赋值挡在类外,但它不能替代业务层面的授权设计。

PHP 对象构造、领域方法、继承写入与外部适配器之间的受控修改关系图
图2:属性读取可以面向展示层开放,写入则集中在构造方法、领域方法或受保护的继承边界;序列化器和 ORM 需要改走明确的重建入口。这是原创静态结构图。

继承场景按需求选择 protected(set)

如果子类确实负责推进父类定义的状态,可以把写权限设为 protected(set)。它仍然会拒绝对象外部赋值,但允许声明类及其子类写入。

status = 'issued';
    }
}

$invoice = new Invoice('INV-1001');
echo $invoice->status; // 允许:外部公开读取

// 禁止:对象外部不在 protected(set) 的写入范围内
// $invoice->status = 'void';
?>

不要为了“以后可能继承”就默认使用 protected(set)。写权限越宽,状态来源越多。另一个容易忽略的规则是:带 private(set) 的属性会自动成为 final 属性,子类不能重新声明它。它限制的是该属性的重声明,不是自动把整个类变成 final。

数组元素和引用同样受写权限约束

开发者常把 $object->roles[] = 'admin' 误认为“只是改数组元素,不是给属性赋值”。PHP 官方规则明确说明,数组属性的这种修改同时涉及读取和写入,因此会服从更严格的 set 可见性。获取属性引用也遵循写入可见性,因为引用可以被用来修改原值。

roles, true)) {
            $this->roles[] = $role;
        }
    }
}

$member = new TeamMember();
$member->addRole('reviewer');

// 禁止:数组追加仍然会触发属性写入权限检查
// $member->roles[] = 'admin';

// 禁止:外部获取引用后可能修改原属性,因此按 set 可见性检查
// $roles =& $member->roles;
?>

这也是迁移时最容易漏掉的地方:全文搜索普通赋值还不够,还要查找数组追加、索引修改、递增递减、复合赋值和按引用传递。属性提升可以直接使用非对称可见性,但同样要求带类型。

迁移 ORM 与序列化器时保留明确重建入口

旧项目的水合器可能依赖对象创建后逐字段赋值。属性改成 private(set) 后,这种外部写入会失败。不要把写权限重新放宽到 public 来迁就基础设施;更可控的做法是让类提供构造方法、命名构造器或专用重建入口。

是否能无缝采用这一模式,还要看具体 ORM、序列化组件和代理生成机制。框架可能通过构造器、反射、生成代理或自定义 hydrator 写入对象,升级前应阅读所用版本的官方兼容说明,并对真实映射路径做回归测试。这里不应该凭语言语法推断所有框架都自动兼容。

发布前按清单检查写入边界

  • 确认生产和 CI 使用 PHP 8.4 或更高版本,避免新语法在旧运行时解析失败。
  • 确认所有非对称可见性属性都有明确类型,且 set 权限没有比读取权限更宽。
  • 搜索直接赋值、数组元素修改、复合赋值、递增递减和按引用传递。
  • 把外部状态变化迁移到构造方法、命名构造器或领域方法,并补齐异常路径测试。
  • 检查子类是否真的需要写入;不需要时优先使用 private(set)。
  • 检查 serializer、ORM、代理类、测试夹具与反序列化流程是否依赖对象外写属性。
  • 把关键状态变化的权限判断、日志和事件放在受控方法中,而不是散落在调用方。

对我来说,非对称属性可见性最适合“读取频繁、写入必须收口”的对象:响应 DTO、配置快照、标识与状态字段、值对象,以及有明确状态机的领域实体。若属性本来就不应该公开读取,普通 private 仍然更直接;若对象只是无行为的数据容器,也不要为了使用新语法硬造领域方法。

常见问题

private(set) 是否等于 readonly?

不等于。private(set) 允许声明类内部多次写入,而 readonly 属性通常强调初始化后的不可变约束。选择前先判断需要的是“谁能写”还是“写几次”。

可以写 protected public(set) 吗?

不可以。写入可见性必须与读取可见性相同或更严格,不能让写入范围比读取范围更宽。

为什么 public 可以省略?

当声明了单独的 set 可见性而未写主可见性时,读取默认是 public。因此 private(set) string $id 是 public private(set) string $id 的简写。

静态属性也能使用非对称可见性吗?

PHP 官方手册注明,静态属性从 PHP 8.5 起可以使用 set 可见性。若项目仍运行 PHP 8.4,应把本文方案用于对象实例属性,不要把 8.5 语法提前带入生产。

采用 private(set) 后还需要 setter 吗?

只有当对象确实需要对外提供某种受控修改时才需要方法。与其提供无条件的 setStatus(),更建议用 pay()、cancel()、rename() 这类表达业务意图的方法。

官方资料

PHP 属性可见性手册:https://www.php.net/manual/en/language.oop5.visibility.php

PHP 非对称可见性 RFC:https://wiki.php.net/rfc/asymmetric-visibility-v2

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>