PHP学习笔记9:类和对象 I

PHP学习笔记9:类和对象 I

image-20211129162010327

图源:php.net

基本概念

定义

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 selfnew 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类中。

从示例中可以发现,selfparent实际上在类定义中充当了当前类和父类的“别名”的作用,这一点在静态函数的类型声明中也有体现。通过使用selfparent可以避免在代码中多次出现实际的类名,也就是说让类定义中的代码与实际类名“解耦”,这样做的好处在于如果代码重构的时候需要修改类名,工作量可能会大大减少。

之前提到过,普通情况下对象的赋值和传递都是引用传递。如果需要一个对象的拷贝而非引用,可以使用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()会被解析为尝试调用$mcfunc方法。需要这样进行调用:

...
($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中编译上边的示例,会显示Child2test方法与父类同名方法不兼容,但并不影响实际运行,这可能是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方法的param1param2,所以出现了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

比较奇怪的是这里访问nullage属性只出现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的对象调用中,将->改为?->即可。此时如果$othernull,则$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这部分内容实在是过多,我会拆分成多篇笔记,这是第一部分。

谢谢阅读。

往期内容

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值