PHP学习笔记11:类和对象 III

PHP学习笔记11:类和对象 III

image-20211129162010327

图源:php.net

继承

php可以使用extends关键字让一个类继承另一个类。继承是OOP三大特性之一,目的是可以在良好封装的基础上更好地实现代码复用。

php中,子类不能将继承自父类的可读可写属性声明为readonly

<?php
class Base{
    protected string $readWrite;
}
class Child extends Base{
    readonly protected string $readWrite;
    // Fatal error: Cannot redeclare non-readonly property Base::$readWrite as readonly Child::$readWrite in ...
}

很明显,这样做违反了里氏替换原则(LSP)。

static

使用static关键字可以定义类静态属性和类静态方法:

<?php
class Counter
{
    private static int $total = 0;
    private static int $nums = 0;
    public function __construct(private int $number)
    {
        self::$total += $number;
        self::$nums++;
    }
    public static function average(): float
    {
        if (self::$nums == 0) {
            return 0;
        }
        return self::$total / self::$nums;
    }
    public function __destruct()
    {
        self::$total -= $this->number;
        self::$nums--;
    }
    public function __toString()
    {
        return (string)$this->number;
    }
}
[$c1, $c2, $c3] = [new Counter(1), new Counter(9), new Counter(10)];
echo "{$c1},{$c2},{$c3}" . PHP_EOL;
echo "average:" . sprintf("%.2f", Counter::average())  . PHP_EOL;
// 1,9,10
// average:6.67

这是一个说明static用途的常见示例,示例中Counter类型的构造函数可以接收一个整数,在构建实例的同时会将对应的数字统计到类属性$total中,并且由类变量$nums记录当前有多少个Counter实例。利用这两个类变量就可以在任意时候计算所有Counter实例的平均值。

范围解析操作符

使用范围解析操作符::可以访问类的静态属性、静态方法和常量。因为在其它示例中已经多次展示,所以这里不再重复说明。

在php 8.0.0之前,使用::也可以访问非静态的属性和方法,但会产生一个Depcated提示,php 8.0,0中,同样的做法会产生错误。

抽象类

抽象类在概念上讲,可接口类似,都可以作为面向对象编程中,对模型抽象出的底层概念,可以用来当做编程时的“接口”使用。

如果一个类有至少一个方法是抽象方法,则这个类必须被定义为抽象类,而子类必须实现所有的抽象方法。

虽然抽象类和接口类似,但它有接口没有的特点,即可以包含实现了内容的普通方法。利用这种特性,我们可以使用模版方法模式来构建一些很有用的抽象类。

这里用一个Web框架中可能使用的Control层的抽象类来说明:

<?php
abstract class Control
{
    protected bool $need_login = true;
    protected array $header = [];
    public function handle_request()
    {
        $this->pre_handle();
        $this->handle();
        $this->after_handle();
    }

    protected function pre_handle()
    {
        if ($this->need_login){
            //执行登录检查
        }
    }

    /**
     * 页面的请求处理逻辑
     */
    abstract protected function handle();

    protected function after_handle()
    {
        //http请求处理后的动作
        //使用模版引擎渲染html页面
        //输出http头
        //输出http body
    }
}

这里定义了一个抽象类Control作为所有Control层类的基类,公有的handle_request方法作为所有Control层实例接收http请求的入口方法。具体的请求逻辑由抽象方法handle来实现,子类必须要实现这个方法。在handle之前和之后分别定义了pre_handleafter_handle方法,这里实现一些通用的http请求处理动作,比如请求前可能需要的登录状态检查,以及请求后的html模版引擎调用和http头输出和cookie设置等。

这里假设利用上面的基类实现一个用户登录页面,利用基类和伪代码填充后可能是这样的:

<?php
require_once './control.cls.php';
class UserLoginControl extends Control{
    protected bool $need_login = false;

    protected function handle()
    {
        //从表单获取用户名和密码
        //查询数据库检查用户名密码是否正确
        //如果正确,将用户信息写入session
        //准备返回数据
    }
}

代码量无疑少了很多,基类的pre_handleafter_handle都直接继承。因为登录页面本身是不需要用登录状态的,所以这里覆盖$need_login属性,将其设置为false

再举一个用户详情页面的例子:

<?php
require_once './control.cls.php';
class UserInfoControl extends Control{
    protected function handle()
    {
        //从数据库读取当前用户数据
        //生成返回数据
    }
}

这个页面更简单。

除了使用抽象类实现模版方法模式来搭建框架代码以外,抽象类还经常被用于各种设计模式中作为“接口”使用。但总的来说能使用真正接口的地方就不要使用抽象类,因为php不支持多继承,但是一个类可以实现多个接口。

接口

接口和抽象类的概念很接近,它可以看作是只包含抽象方法的抽象类。在不支持多继承的语言中,接口往往比抽象类更常使用。因为接口更为灵活:一个类可以实现多个接口。

通过将一类功能抽象为一个接口,可以让处理这些功能的函数使用接口而不是具体的类,也就是说使用接口可以让具体实现和调用“解耦”,调用方不需要知道具体的实现细节,这就是策略模式。

这里举一个经典的策略模式例子:

<?php
interface Duck{
    public function quack();
    public function swim();
}
<?php
require_once './duck.cls.php';
class GreenDuck implements Duck
{
    public function quack()
    {
        echo "green duck is quacking..." . PHP_EOL;
    }

    public function swim()
    {
        echo "green duck is swimming..." . PHP_EOL;
    }
}
<?php
require_once './duck.cls.php';
require_once './green_duck.cls.php';
function play_duck(Duck $duck)
{
    $duck->quack();
    $duck->swim();
}
$green_duck = new GreenDuck();
play_duck($green_duck);

当然,这个例子过于精简,并不能说明接口的威力,在这个示例中使用抽象类也是同样可以的。完整的策略模式更复杂一些,比如会将“飞行”和“鸣叫”这两种行为抽象为两组策略,这时候就需要使用两个接口来分别表示。然后用两组具体实现的类来分别实现两个接口,再利用这两组策略来组合成各种有不同行为的鸭子。要了解完整的策略模式可以阅读设计模式 with Python1:策略模式,虽然是用Python实现的,但OOP方面的概念并没有差别。

接口中还可以定义魔术方法:

<?php
interface Stringer
{
    public function __toString(): string;
}

所有实现了Stringer接口的都意味着它们实现了__toString魔术方法,可以利用这点来进行某些后续处理:

<?php
require_once './stringer.cls.php';
class Pointer implements Stringer
{
    public function __construct(private int $x, private int $y)
    {
    }

    public function __toString(): string
    {
        return "({$this->x},{$this->y})";
    }
}
function print_stringer(Stringer $stringer)
{
    echo "{$stringer}" . PHP_EOL;
}
$p = new Pointer(1, 5);
print_stringer($p);

当然这个示例同样没什么实际意义,因为现实中echo等语句都能很好地使用__toString魔术方法将对象合理地转换为字符串后输出,并不需要额外定义一个Stringer接口。

接口的方法只能是public的。这点很好理解,因为接口不能被继承。并且接口的主要用途是解耦调用方和实际实现逻辑,非public的方法对调用方是没有意义的。

虽然php支持extends interface_name这样的写法,但本质上依然是实现一个接口,而非继承。并且建议不要那样写。

接口中也可以定义常量,其使用方式与类常量一样,某些实现了接口的类会用到的接口相关的常量可以在接口中定义,比如一个类似于Go中的error类型的表示错误信息的接口:

<?php
interface Error
{
    //错误类型
    const ERROR_USER = 1;
    const ERROR_UNKNOWN = 2;
    const ERROR_DB = 3;
    const ERROR_WEB = 4;

    /**
     * 返回错误信息
     */
    public function error(): string;
}

需要注意的是,因为有指名传参这个新特性存在,实现了接口的类的方法参数最好要与接口中的方法参数命名一致。

Trait

"trait"可以被翻译为“特性”。

php的类是不支持多继承的,这在某些情况下可能会造成一些代码“冗余”:

<?php
class Rocket
{
    private static $counter = 0;
    private static $name = __CLASS__;
    public function __construct()
    {
        self::$counter++;
    }
    public static function counter_print()
    {
        echo "now have " . self::$counter . " " . self::$name . PHP_EOL;
    }
}
<?php
class Plane
{
    private static $counter = 0;
    private static $name = __CLASS__;
    public function __construct()
    {
        self::$counter++;
    }
    public static function counter_print()
    {
        echo "now have " . self::$counter . " " . self::$name . PHP_EOL;
    }
}
<?php
require_once "./rocket.cls.php";
require_once "./plane.cls.php";
$rocket = new Rocket();
$plane = new Plane();
Rocket::counter_print();
Plane::counter_print();

这里有两个类PlaneRocket,它们分别代表两类产品,共同点是它们在生产以后都要进行统计,需要时刻掌握它们的生产数据并进行输出。

显而易见的是利用静态变量进行统计和输出结果的代码是重复的,如果我们要对这部分代码进行复用,就需要考虑使用继承,设计一个PlaneRocket的基类,在基类中实现相关功能。但这样做有一些局限性,比如PlaneRocket的区别很大,不适合有一个共同的基类。还比如这种统计的功能在系统中存在的非常广泛,其它许多类都需要,这种情况下就更无法为了这一个功能设计一个所有类的基类。而且更为关键的一点在于设计模式中有一条原则:继承层次不要太多,一般而言是不要超过三层,这意味着我们更不应该为了诸如此类的复用理由去随意添加继承层级。

当然,如果你了解过设计模式,应该知道应对这样的问题,更普遍的解决方案应当是使用组合,即设计一个专门负责统计的类,然后将其实例组合到当前类中。但这里为了说明Trait的作用,选择使用Trait来解决问题:

<?php
trait Counter
{
    private static int $counter = 0;
    private static string $couner_name = "";
    public static function counter_print(): void
    {
        echo "now have " . self::$counter . " " . self::$couner_name . PHP_EOL;
    }
}
<?php
require_once './counter.cls.php';
class Plane
{
    use Counter;
    public function __construct()
    {
        self::$counter++;
        self::$couner_name = __CLASS__;
    }
}
<?php
require_once './counter.cls.php';
class Rocket
{
    use Counter;
    public function __construct()
    {
        self::$counter++;
        self::$couner_name = __CLASS__;
    }
}
<?php
require_once "./rocket.cls.php";
require_once "./plane.cls.php";
$rocket = new Rocket();
$plane = new Plane();
Rocket::counter_print();
Plane::counter_print();

这是一种“平行扩展”类的能力,即我们没有增加PlaneRocket的继承层级的同时,让他们共享了一部分代码,实现了功能扩展。

trait可以简单的看作是一种“残缺的类”,它在语法上和普通的类很相似,也有属性和方法,甚至是静态属性和静态方法。但本身并不能作为一个完整的类来使用。其存在的意义在于水平扩展其它的类。

这种概念有点像是安卓开发中的片段和页面的关系,片段本身并不能存在,必须要嵌入页面中才能使用。而不同的页面可以使用同一个片段,以此来实现UI代码的复用。

一个类要使用trait,只需要在类定义中添加use trait_name即可,作用就像是直接从trait中加载属性和方法定义到当前类中。

类方法和trait方法是可能出现同名的,这种情况下类方法的优先级高于trait中的同名方法。总的来说方法的优先级是:类方法>trait方法>父类方法。

这里通过代码实际检验:

<?php
trait Trait1
{
    public function test(): void
    {
        echo "Trait1::test() is called." . PHP_EOL;
    }
}
class Base
{
    public function test(): void
    {
        echo "Base::test() is called." . PHP_EOL;
    }
}
class Child extends Base{
    use Trait1;
}
$c = new Child;
$c->test();
// Trait1::test() is called.

可以看到trait的同名方法是优先于父类方法的。

...
class Child extends Base
{
    use Trait1;
    public function test(): void
    {
        echo "Child::test() is called." . PHP_EOL;
    }
}
$c = new Child;
$c->test();
// Child::test() is called.

可以看到子类中的同名方法的优先级高于trait中的方法。

可以同时在一个类中导入多个trait,如果多个trait有同名方法,就会出现冲突:

...
trait Trait2
{
    public function test(): void
    {
        echo "Trait2:test() is called." . PHP_EOL;
    }
}
class Child extends Base
{
    use Trait1, Trait2;
    
// Fatal error: Trait method Trait2::test has not been applied as Child::test, because of collision with Trait1::test in ...
}
$c = new Child;
$c->test();

此时要使用insteadof语句来指定冲突的方法具体要使用哪一个:

...
class Child extends Base
{
    use Trait1, Trait2 {
        Trait2::test insteadof Trait1;
    }
}
$c = new Child;
$c->test();
// Trait2:test() is called.

怎么说呢,我估计超过一半的php程序员都不知道有trait这个东西存在,我之前也是如此。感觉这很像是php的设计者为了让php能“伪多继承”而做出的努力,很难说这种功能有无用武之地,毕竟像Python之类的支持多继承的语言本身是不支持interface的,多继承的Mixin风格导致和传统OOP区别很大,而php这种两头通吃的做法…我很难评判。

好了,就到这里吧,整理OOP部分的确是个累人的活。

谢谢阅读。

往期内容

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值