图源:
定义
php的类定义语法与其它语言几乎没有区别:
<?php
class Student
{
protected string $name = "";
protected int $age = 0;
public function print(): void
{
echo "Student(name:{$this->name}, age:{$this->age})" . PHP_EOL;
}
}
像上面示例中的那样,在类定义中$this
是一个当前对象的引用,通过它可以访问当前对象的属性和方法。
几乎所有的编程语言都习惯于使用首字母大写的驼峰方式命名类名,当然Go是个例外,因为其命名空间中名称首字母是否大小写取决于访问控制。
一般来说类定义所在的文件命名并不需要特殊对待,但php作为一门脚本语言,项目中往往会存在一些非OOP的php脚本程序,为了区分,将类定义文件命名为
class_name.cls.php
是一个不错的选择。不要试图在源文件命名上使用大写字母,一些平台(比如Windows)的文件系统不区分路径的大小写。
new
和其它语言一样,使用new
关键字可以创建并初始化类实例,并返回一个该实例的引用。
比较特别的是,如果类没有定义构造函数,可以缺省()
:
<?php
require_once "./student.cls.php";
$std = new Student();
$std2 = new Student;
$std->print();
$std2->print();
// Student(name:, age:0)
// Student(name:, age:0)
在类定义中,可以使用new self
和new parent
创建当前类和父类的实例:
<?php
require_once "./student.cls.php";
//优秀学生
class OutstandingStudent extends Student
{
public static function get_outstanding_student_sample(): self
{
$os = new self;
$os->name = "sample outstanding student";
$os->age = 10;
return $os;
}
public static function get_student_sample(): parent
{
$std = new parent;
$std->name = "sample staudent";
$std->age = 20;
return $std;
}
}
测试:
<?php
require_once "./student.cls.php";
require_once "./outstanding_student.cls.php";
$std = OutstandingStudent::get_student_sample();
$osStd = OutstandingStudent::get_outstanding_student_sample();
var_dump($std instanceof Student);
var_dump($std instanceof OutstandingStudent);
var_dump($osStd instanceof OutstandingStudent);
// bool(true)
// bool(false)
// bool(true)
当然,这里只是为了说明如何使用new parent
,像get_student_sample
这种静态函数完全没有必要在子类中实现,应当放在Student
类中。
从示例中可以发现,self
和parent
实际上在类定义中充当了当前类和父类的“别名”的作用,这一点在静态函数的类型声明中也有体现。通过使用self
和parent
可以避免在代码中多次出现实际的类名,也就是说让类定义中的代码与实际类名“解耦”,这样做的好处在于如果代码重构的时候需要修改类名,工作量可能会大大减少。
之前提到过,普通情况下对象的赋值和传递都是引用传递。如果需要一个对象的拷贝而非引用,可以使用clone
关键字:
<?php
require_once "./student.cls.php";
$std = new Student();
$std2 = $std;
$std3 = clone $std;
var_dump($std === $std2);
var_dump($std3 === $std);
// bool(true)
// bool(false)
使用===
比较两个引用变量,实际上是比较他们引用的地址(指针值)是否相等,所以这里可以判断是否为同一个对象。
属性和方法
php的属性和方法属于不同的命名空间,这意味着可以设置相同名称的属性和方法:
<?php
class MyClass{
public $attr = "attribute";
public function attr(){
echo "attr method is called".PHP_EOL;
}
}
$mc = new MyClass;
echo $mc->attr.PHP_EOL;
// attribute
echo $mc->attr();
// attr method is called
通过->
访问的是属性还是方法取决于名称后边是否有()
,就像上面展示的那样。
如果属性是一个匿名函数,就无法用普通的方式调用:
<?php
class MyClass
{
public $func = null;
public function __construct()
{
$this->func = function () {
echo "non named function is called." . PHP_EOL;
};
}
}
$mc = new MyClass;
$mc->func();
// Fatal error: Uncaught Error: Call to undefined method MyClass::func() in ...
要定义一个匿名函数作为对象属性,只能在构造函数中初始化属性,在类的属性定义中会提示语法错误。
$mc->func()
会被解析为尝试调用$mc
的func
方法。需要这样进行调用:
...
($mc->func)();
// non named function is called.
签名兼容
子类使用同名方法对父类方法进行覆盖时,需要遵守签名兼容规则。要完整解释这个规则很麻烦,但我的理解是:要保证原有对父类方法的调用方式对子类依然是可行的。
这种签名兼容规则也被称为里氏替换原则(Liskov Substitution Principle),简称 LSP。事实上这是为了满足OOP的多态调用,子类实例必须能够被父类句柄接收,并进行调用。也就是说父类某个方法的调用方式在子类实例中必须是合法的。
下面举几个可行的示例:
<?php
class BaseClass
{
public function test($param)
{
echo "BaseClass->test($param) is called." . PHP_EOL;
}
}
class Child1 extends BaseClass
{
public function test($param = "hello")
{
parent::test($param);
}
}
class Child2 extends BaseClass
{
public function test($param, $param2 = 2)
{
parent::test($param);
}
}
$c1 = new Child1;
$c2 = new Child2;
$c1->test(1);
$c2->test(1);
也就是说,在这个示例中,继承BaseClass
的子类如果要覆盖test
方法,必须要保证能够接受至少1个参数的调用,即test(1)
这样的调用方式。
在VSC中编译上边的示例,会显示
Child2
的test
方法与父类同名方法不兼容,但并不影响实际运行,这可能是IDE的一个bug。
下面是不被允许的情况:
...
class Child1 extends BaseClass
{
public function test()
// Fatal error: Declaration of Child1::test() must be compatible with BaseClass::test($param) in ...
{
parent::test(1);
}
}
很明显,这里的test
方法已经和父类方法不兼容,因为它不能接受参数。
再举一个例子:
....
class Child1 extends BaseClass
{
public function test($param, $param2)
// Fatal error: Declaration of Child1::test($param, $param2) must be compatible with BaseClass::test($param) in ...
{
parent::test(1);
}
}
这里的test
方法同样与父类方法不能兼容,因为它必须接收2个参数,而父类方法只接收1个参数。
需要说明的是,方法的签名兼容规则并不包含参数名称完全一致,理论上子类覆盖的方法参数名是可以随意修改的。但是因为php增加了指名传参这种新特性,为了避免某些情况下指名传参调用违反“LSP”,需要尽量让参数名称保持一致。
我们来看一个相关的示例:
class Base
{
public function test($param1, $param2)
{
echo "parma1:{$param1},param2:{$param2}" . PHP_EOL;
}
}
class Child extends Base
{
public function test($a, $b)
{
parent::test($a, $b);
}
}
这里子类Child
重写了父类的test
方法,但修改了参数名称。在普通情况下的调用是不会出现问题的:
$c = new Child;
$c->test(1, 2);
$c->test(a: 1, b: 2);
// parma1:1,param2:2
// parma1:1,param2:2
只涉及位置参数的多态调用也不会存在问题:
function exec_test(Base $base)
{
$base->test(1, 2);
}
exec_test($c);
// parma1:1,param2:2
但如果是指名传参的多态调用,就会出现问题:
function exec_test2(Base $base)
{
$base->test(param1:1, param2:2);
}
exec_test2($c);
// Fatal error: Uncaught Error: Unknown named parameter $param1 in ...
因为这里实际上是使用了一个Base
类型的句柄来接收了一个Child
类型的实例,在执行test
方法的时候实际上是调用的Child
类型的test
方法,而制定的参数名称却是父类test
方法的param1
和param2
,所以出现了Unknown named parameter
这样的错误信息。
::class
::class
可以用于获取类的完整名称,这对于获取使用命名空间的类名称是有帮助的:
<?php
namespace xyz\icexmoon\php_notes\ch9;
class MyClass
{
public static function get_full_clsname(): string
{
return self::class;
}
}
echo MyClass::get_full_clsname() . PHP_EOL;
从php8.0.0开始,对象也可以使用::class
获取对应类的完整名称:
namespace xyz\icexmoon\php_notes\ch9;
class MyClass
{
}
$mc = new MyClass;
echo $mc::class.PHP_EOL;
// xyz\icexmoon\php_notes\ch9\MyClass
这和get_class
函数的效果是相同的:
...
echo get_class($mc).PHP_EOL;
// xyz\icexmoon\php_notes\ch9\MyClass
null safe调用
某些时候,在对方法或函数传入的对象进行处理时,需要判断是否为null
。如果不判断直接使用,就会在一个null
上访问属性或方法,这会直接导致程序错误:
<?php
class Student
{
public string $name = "";
public int $age = 0;
public function compare(?Student $other): int
{
return match (true) {
$this->age < $other->age => -1,
$this->age == $other->age => 0,
$this->age > $other->age => 1,
};
}
}
$std1 = new Student;
$std2 = new Student;
$std1->age = 15;
$std2->age = 18;
echo "result:{$std1->compare($std2)}" . PHP_EOL;
echo "result:{$std1->compare(null)}" . PHP_EOL;
// result:-1
// Warning: Attempt to read property "age" on null in ...
// Warning: Attempt to read property "age" on null in ...
// Warning: Attempt to read property "age" on null in ...
// result:1
比较奇怪的是这里访问null
的age
属性只出现warning
,没有导致程序退出。不过无论如何,都应当避免这种情况出现,正确的代码应当加入null
值检测:
<?php
class Student
{
...
public function compare(?Student $other): int
{
if (is_null($other)){
return 1;
}
...
}
}
...
// result:-1
// result:1
除了这种传统方式以外,php8.0.0加入了一种新的机制:null safe 调用:
<?php
class Student
{
...
public function compare(?Student $other): int
{
return match (true) {
$this->age < $other?->age => -1,
$this->age == $other?->age => 0,
$this->age > $other?->age => 1,
};
}
}
...
// result:-1
// result:1
就像上面展示的,使用起来很容易,在可能是null
的对象调用中,将->
改为?->
即可。此时如果$other
是null
,则$other?->age
就会立即返回一个null
值作为结果。也就是说实际运行中就是$this->age < null
这样的中间代码,而null
又会在数值比较表达式中被转换为0进行比较,最后得出结果。
最重要的是,使用?->
避免了在null
对象出现时的异常或报错。
看起来很不错,但我觉得这会掩盖一些bug,需要谨慎使用。
属性
和C++或Java类似,php的属性和方法在class
中定义,并可以使用访问修饰符public/protected/private
修饰。在加入新特性类型声明后,也可以使用类型声明来限定属性的类型:
<?php
class MyClass{
public int $attr1;
protected string $attr2;
private array $attr3;
}
可以在声明属性的同时初始化:
<?php
class MyClass{
public int $attr1 = 0;
protected string $attr2 = "";
private array $attr3 = array();
}
但是只能使用简单的表达式和字面量初始化,不能使用其它的变量或者函数调用:
<?php
$a = 100;
class MyClass
{
public int $attr1 = $a;
// Fatal error: Constant expression contains invalid operations in ...
protected string $attr2 = implode(',', [1, 2, 3]);
// Fatal error: Constant expression contains invalid operations in ...
}
下面的方式是允许的:
<?php
class MyClass
{
public int $attr1 = 1 + 2 + 3;
protected string $attr2 = "hello" . "world";
private array $attr3 = array();
}
类型声明
从php 7.4.0开始,可以使用类型声明限定属性类型,但不能将属性声明为callable
:
<?php
class Student{
public string $name;
public int $age;
}
使用了类型声明的属性在使用前必须初始化,否则会产生Error
:
<?php
class Student{
public string $name;
public int $age;
}
$std = new Student;
echo $std->name.PHP_EOL;
// Fatal error: Uncaught Error: Typed property Student::$name must not be accessed before initialization in ...
要解决这个问题,最简单的方式是在声明的同时初始化:
<?php
class Student{
public string $name = "";
public int $age = 0;
}
...
当然,也可以在构造函数中或者别的地方初始化。
readonly
php8.1.0加入了一个新关键字readonly
,可以使用它将一个属性声明为readonly
,此时该属性在初始化后就不能再被改变:
<?php
class MyClass{
public readonly string $attr;
public function __construct()
{
$this->attr = "hello";
}
}
$mc = new MyClass;
echo $mc->attr.PHP_EOL;
// hello
$mc->attr = "bye";
// Fatal error: Uncaught Error: Cannot modify readonly property MyClass::$attr in ...
echo $mc->attr.PHP_EOL;
这和C++或Java中的
const
限定符的作用是类似的。
需要注意的是,readonly
仅能作用于声明了类型的属性,对于没有声明的属性或者声明为mixed
类型的属性,不能使用:
class MyClass{
public readonly $attr;
// Fatal error: Readonly property MyClass::$attr must have type in ...
...
}
...
readonly
也不能用于静态属性:
<?php
class MyClass{
public static readonly string $attr;
// Fatal error: Static property MyClass::$attr cannot be readonly in ...
}
被声明为readonly
的属性仅能在类定义中初始化,在其它地方初始化会报错:
<?php
class MyClass{
public readonly string $attr;
}
$mc = new MyClass;
$mc->attr = "bye";
// Fatal error: Uncaught Error: Cannot initialize readonly property MyClass::$attr from global scope in ...
echo $mc->attr.PHP_EOL;
readonly
声明的属性不能在定义的同时初始化:
<?php
class MyClass{
public readonly string $attr = 'hello';
// Fatal error: Readonly property MyClass::$attr cannot have default value in ...
}
原因是这样做就与const
声明的常量没有区别了。类似的属性应当被定义为类常量,而非使用readonly
。
readonly
存在与C++中的const
限定符类似的问题,即readonly
仅能限定变量本身不能改变,但不能限定变量中引用的其它数据不发生变化。
在展示示例之前,先重构之前的一个工具函数array.php
:
<?php
function print_arr(array $arr)
{
echo convert_array_to_str($arr) . PHP_EOL;
}
function convert_array_to_str(array $arr): string
{
$ls = array();
$ls[] = '[';
$index = 0;
$len = count($arr);
foreach ($arr as $key => $value) {
$ls[] = "{$key}:{$value}";
if ($index < $len - 1) {
$ls[] = ', ';
}
$index++;
}
$ls[] = ']';
return implode('', $ls);
}
示例代码:
<?php
require_once "../util/array2.php";
class MyClass{
public readonly array $arr;
public function __construct(mixed &...$params)
{
$this->arr = $params;
}
public function __toString()
{
return convert_array_to_str($this->arr);
}
}
[$a, $b, $c] = [1, 3, 5];
$mc = new MyClass($a, $b, $c);
echo $mc.PHP_EOL;
// [0:1, 1:3, 2:5]
$a = 99;
echo $mc.PHP_EOL;
// [0:99, 1:3, 2:5]
在上面这个例子中,MyClass
的属性$arr
虽然被声明为readonly
的数组,但实际上其中的元素依然可以改变。这是因为它在初始化的时候是被初始化为一个数组的引用。也就是说这个引用本身并没有发生变化,但这个引用指向的数组的元素是可以被改变的。
类常量
类常量可以看做是一个声明为readonly
的类静态属性,其默认可见性是public
。可以像访问普通的类静态变量那样访问类常量:
<?php
class MyClass
{
const const1 = "1";
const const2 = 1.5;
const const3 = 1 + 2;
}
echo MyClass::const1 . PHP_EOL;
echo MyClass::const2 . PHP_EOL;
echo MyClass::const3 . PHP_EOL;
// 1
// 1.5
// 3
类常量可以被子类重新定义:
<?php
...
class Child extends MyCLass{
const const1 = "hello";
}
echo Child::const1 . PHP_EOL;
echo Child::const2 . PHP_EOL;
echo Child::const3 . PHP_EOL;
// hello
// 1.5
// 3
可以使用"类变量"访问类常量:
...
$className = "MyClass";
echo $className::const1 . PHP_EOL;
echo $className::const2 . PHP_EOL;
echo $className::const3 . PHP_EOL;
// 1
// 1.5
// 3
接口中也可以定义常量:
<?php
interface MyInterface
{
const const1 = "hello";
}
echo MyInterface::const1 . PHP_EOL;
// hello
可以用常量来构成常量:
<?php
class MyClass
{
const ONE = 1;
const TWO = self::ONE * 2;
const THREE = self::TWO + 1;
}
echo MyClass::ONE . PHP_EOL;
echo MyClass::TWO . PHP_EOL;
echo MyClass::THREE . PHP_EOL;
// 1
// 2
// 3
从php7.1.0开始,类常量也可以使用访问修饰符。
OOP这部分内容实在是过多,我会拆分成多篇笔记,这是第一部分。
谢谢阅读。
文章评论