特质 vs 接口

394

我最近一直在努力学习PHP,但是我发现我对traits这个概念有些困惑。我理解水平代码复用的概念,以及不一定要从抽象类中继承的想法。但我不明白的是:使用traits和使用接口之间的关键区别是什么?

我尝试搜索一些不错的博客文章或者是解释何时使用其中一个而不是另一个的文章,但是到目前为止我找到的例子都很相似,甚至可以说是一模一样。


6
接口中的函数体内没有任何代码。实际上,它们并没有任何函数体。 - hakre
3
尽管我的回答得到了很多赞,但我想记录一下我通常是反对特质/混入的。查看此聊天记录以了解特质通常如何削弱良好的面向对象编程实践。 - user895378
2
我持相反观点。在 traits 出现之前和之后,我已经使用 PHP 工作多年,我认为很容易证明它们的价值。只需阅读这个实际例子,它使“图像模型”也能像 Imagick 对象一样行走和交谈,而不需要在 traits 出现之前所需的所有臃肿代码。 - quickshiftin
1
特质和接口类似,主要区别在于特质允许你实现方法,而接口则不允许。 - John
接口就像需求,特征就像附加组件。顺便说一下,Java、Kotlin和Rust将特征和接口合并成了同一种东西。 - quant2016
接口就像需求,特质就像附加功能。顺便说一下,Java、Kotlin和Rust将特质和接口合并为一个东西。 - quant2016
13个回答

600

公共服务通告:

我要声明的是,我认为traits几乎总是一种代码异味,应该避免使用继承,而是使用组合。在我看来,单一继承经常被滥用到成为反模式的程度,而多重继承只会加剧这个问题。在大多数情况下,通过偏好组合而不是继承(无论是单一还是多重)可以更好地为您服务。如果您仍然对traits及其与接口的关系感兴趣,请继续阅读...


让我们从这里开始:

  

面向对象编程(OOP)可能是一个难以理解的范例。   仅仅因为你正在使用类并不意味着你的代码是 面向对象(OO)的。

要编写OO代码,您需要了解OOP实际上是关于您的对象能力的。您必须考虑类的能力,而不是它们实际执行的操作。这与传统的过程化编程形成鲜明对比,后者的重点是使代码“做某事”。

如果OOP代码是关于规划和设计的,那么接口就是蓝图,对象就是完全构建的房屋。而traits只是一种帮助按照蓝图(接口)构建房屋的方法。

接口

那么,为什么我们要使用接口呢?简单地说,接口使我们的代码更加健壮。如果您对此表示怀疑,请问任何被迫维护未针对接口编写的遗留代码的人。

接口是程序员和他/她的代码之间的合同。接口说:“只要你按照我的规则来实现我,你可以随意实现我,并且我保证不会破坏你的其他代码。”

因此,作为一个例子,考虑一个真实的场景(没有汽车或小部件):

  

你想为Web应用程序实现一个缓存系统,以减少服务器负载

您首先编写一个使用APC缓存请求响应的类:

class ApcCacher
{
  public function fetch($key) {
    return apc_fetch($key);
  }
  public function store($key, $data) {
    return apc_store($key, $data);
  }
  public function delete($key) {
    return apc_delete($key);
  }
}

然后,在您的HTTP响应对象中,您在执行生成实际响应的所有工作之前,检查是否有缓存命中:

class Controller
{
  protected $req;
  protected $resp;
  protected $cacher;

  public function __construct(Request $req, Response $resp, ApcCacher $cacher=NULL) {
    $this->req    = $req;
    $this->resp   = $resp;
    $this->cacher = $cacher;

    $this->buildResponse();
  }

  public function buildResponse() {
    if (NULL !== $this->cacher && $response = $this->cacher->fetch($this->req->uri()) {
      $this->resp = $response;
    } else {
      // Build the response manually
    }
  }

  public function getResponse() {
    return $this->resp;
  }
}
这种方法很有效。但是也许几周后,您决定使用基于文件的缓存系统而不是APC。现在,您必须更改控制器代码,因为您已经将控制器编程为使用 ApcCacher类的功能,而不是表达 ApcCacher类能力的接口。假设您将 Controller 类依赖于 CacherInterface 而不是具体的 ApcCacher,如下所示:
// Your controller's constructor using the interface as a dependency
public function __construct(Request $req, Response $resp, CacherInterface $cacher=NULL)

为此,您可以这样定义接口:

interface CacherInterface
{
  public function fetch($key);
  public function store($key, $data);
  public function delete($key);
}

你需要让你的ApcCacher和新的FileCacher类都实现CacherInterface接口,并编写Controller类来使用接口所需的功能。

这个例子(希望如此)演示了通过编写针对接口进行的程序设计,你可以更改类的内部实现而不用担心更改可能会破坏其他代码的问题。

Traits 特性

另一方面,特性只是一种重用代码的方法。不应该认为接口是特性的相互排斥的替代品。事实上,创建满足接口所需功能的特性是理想的用例

只有当多个类共享相同功能(可能由同一接口指定)时,才应使用特性。对于单个类提供功能,使用特性没有意义:这只会混淆类的作用,更好的设计是将特性的功能移入相关类中。

考虑以下特性实现:

interface Person
{
    public function greet();
    public function eat($food);
}

trait EatingTrait
{
    public function eat($food)
    {
        $this->putInMouth($food);
    }

    private function putInMouth($food)
    {
        // Digest delicious food
    }
}

class NicePerson implements Person
{
    use EatingTrait;

    public function greet()
    {
        echo 'Good day, good sir!';
    }
}

class MeanPerson implements Person
{
    use EatingTrait;

    public function greet()
    {
        echo 'Your mother was a hamster!';
    }
}

一个更具体的例子:想象一下,你的 FileCacherApcCacher 从接口讨论中使用相同的方法来确定缓存条目是否过时并应该被删除(显然在现实生活中不是这种情况,但跟着想象一下)。你可以编写一个 trait,并允许两个类都使用它来满足共同的接口需求。

最后提醒一点:要小心不要过度使用 traits。通常,当独特的类实现足够时,traits 会被用作设计不佳的替代品。您应将 traits 限制为实现最佳代码设计的接口要求。


85
我本来只是想要上面提供的快速简洁的答案,但我必须说你给出了一份优秀的深入解答,这将有助于让其他人更清楚地区分这两者。赞! - datguywhowanders
43
"创建能够满足给定类接口所需功能的特征是一种理想的使用情况。" 确认:+1 - Alec Gorge
7
在其他语言中,mixin与PHP中的traits相似,这样说是否公平? - Eno
7
就所有实际目的而言,我会说PHP的特质实现与多重继承是一样的。 值得注意的是,如果PHP中的一个特质指定了静态属性,那么使用该特质的每个类都将拥有其自己的静态属性副本。更重要的是……由于查询特质时此帖子现在在搜索引擎结果页面(SERP)上排名极高,因此我将在页面顶部添加一个公共服务公告,请您阅读它。 - user895378
3
+1 对于深入解释。我来自 Ruby 背景,在那里 mixins 被广泛使用;只是想顺便说一句,我们通常使用的一个好的经验法则可以翻译成 PHP 为 "不要在 traits 中实现会改变 $this 的方法"。这可以避免许多疯狂的调试会话…… mixins 也不应该对它将被混合的类做任何假设(或者你应该非常清楚并将依赖关系降到最低)。在这方面,我认为您提出的 traits 实现接口的想法很好。 - m_x
显示剩余16条评论

260

一个接口定义了一组方法,实现类必须实现这些方法。

当一个特质被use后,方法的实现也会随之传递,而这在接口中不会发生。

这是最大的区别。

来自PHP横向重用RFC:

对于单继承语言(如PHP)中的代码重用,特质是一种机制。 特质旨在通过使开发人员能够在不同的类层次结构中自由地重复使用一组方法,从而减少单继承的某些限制。


2
@JREAM 在实践中,没有什么。然而在现实生活中,却要多得多。 - Alec Gorge
83
除了特质根本不是接口之外。接口是可以进行检查的规范。特质无法进行检查,因此它们仅限于实现。它们与接口完全相反。RFC中的那行文字是错误的... - ircmaxell
217
特质本质上是一种语言辅助的复制粘贴。 - Shahid
12
那不是隐喻,而是扭曲了一个词的含义。这就像把一个盒子描述成一个有体积的表面一样。 - cleong
7
扩展ircmaxell和Shadi的评论:您可以通过instanceof检查对象是否实现了接口,并且可以通过方法签名中的类型提示确保方法参数实现了接口。您无法对trait执行相应的检查。 - Brian D'Astous
显示剩余9条评论

76

trait本质上是PHP实现的mixin,它是一组扩展方法。通过添加trait可以将其添加到任何类中,从而使这些方法成为该类实现的一部分,但不使用继承。

来自PHP手册(强调我的):

Traits是在PHP等单继承语言中进行代码重用的机制。...... 它是传统继承的补充,并使行为的水平组合成为可能;也就是说,应用类成员而无需继承。

示例:

trait myTrait {
    function foo() { return "Foo!"; }
    function bar() { return "Bar!"; }
}

有了上述定义的特性,现在我可以执行以下操作:

class MyClass extends SomeBaseClass {
    use myTrait; // Inclusion of the trait myTrait
}

此时,当我创建一个 MyClass 类的实例时,它有两个方法,分别称为 foo()bar() - 它们来自于 myTrait。请注意,trait 定义的方法已经有了方法体,而 Interface 定义的方法则没有。

另外,像许多其他语言一样,PHP 使用单继承模型,这意味着类可以从多个接口派生,但不可以从多个类派生。但是,在 PHP 中,一个类可以包含多个 trait,这允许程序员包含可重用的代码片段,就像包含多个基类一样。

需要注意的几点:

                      -----------------------------------------------
                      |   Interface   |  Base Class   |    Trait    |
                      ===============================================
> 1 per class         |      Yes      |       No      |     Yes     |
---------------------------------------------------------------------
Define Method Body    |      No       |       Yes     |     Yes     |
---------------------------------------------------------------------
Polymorphism          |      Yes      |       Yes     |     No      |
---------------------------------------------------------------------

多态性:

在早期的示例中,MyClass 扩展了 SomeBaseClass,因此MyClass SomeBaseClass 的一个实例。换句话说,像SomeBaseClass[] bases这样的数组可以包含MyClass的实例。同样地,如果MyClass 扩展了 IBaseInterface,那么IBaseInterface[] bases数组可以包含MyClass的实例。使用trait时没有这样的多态构造 - 因为trait本质上只是为方便程序员而被复制到每个使用它的类中的代码。

优先级:

正如手册所述:

  

从基类继承的成员将被插入Trait的成员覆盖。 优先顺序是当前类的成员覆盖Trait方法,这些方法再覆盖继承的方法。

因此 - 考虑以下情况:

class BaseClass {
    function SomeMethod() { /* Do stuff here */ }
}

interface IBase {
    function SomeMethod();
}

trait myTrait {
    function SomeMethod() { /* Do different stuff here */ }
}

class MyClass extends BaseClass implements IBase {
    use myTrait;

    function SomeMethod() { /* Do a third thing */ }
}
创建以上的 MyClass 实例时,会发生以下情况:
1. 接口 IBase 要求提供一个名为 SomeMethod() 的无参函数。 2. 基类 BaseClass 提供了此方法的实现,满足上述需求。 3. Trait myTrait 也提供了一个名为 SomeMethod() 的无参函数,并覆盖了 BaseClass 的版本。 4. 类 MyClass 提供了自己版本的 SomeMethod(),并覆盖了 trait 版本。
结论: 1. 接口不能提供默认方法实现,但 trait 可以。 2. 接口是多态、继承构造 - trait 不是。 3. 单个类中可以使用多个接口和 trait。

5
“Trait”类似于C#中的“抽象类”的概念。但实际上,“抽象类”在PHP和C#中都存在。我认为,在PHP中,“trait”更像是C#中由扩展方法构成的静态类,但不需要基于类型进行约束,因为“trait”可以被几乎任何类型使用,而扩展方法只能扩展一个类型。 - BoltClock
1
非常好的评论 - 我同意你的观点。在重新阅读后,这是一个更好的比喻。不过,我认为将其视为“mixin”更好 - 当我重新审视我的回答开头时,我已经更新了它。感谢您的评论,@BoltClock! - Troy Alford
1
我认为扩展方法与C#没有任何关系。 扩展方法添加到单个类类型(当然要尊重类层次结构),其目的是增强类型的附加功能,而不是在多个类之间“共享代码”并且造成混乱。 这是无法比较的! 如果需要重用某些内容,通常意味着它应该有自己的空间,例如单独的类,与需要共同功能的类相关联。 实现可能因设计而异,但大致如此。 特质只是另一种编写低质量代码的方式。 - Sofija
一个类可以有多个接口吗?我不确定我是否理解了你的图表,但是类X实现Y、Z是有效的。 - Yann Chabot

32

我认为traits对于创建包含方法的类,这些方法可以作为多个不同类的方法使用非常有用。

例如:

trait ToolKit
{
    public $errors = array();

    public function error($msg)
    {
        $this->errors[] = $msg;
        return false;
    }
}

您可以在任何使用此特征的类中拥有并使用此“error”方法。

class Something
{
    use Toolkit;

    public function do_something($zipcode)
    {
        if (preg_match('/^[0-9]{5}$/', $zipcode) !== 1)
            return $this->error('Invalid zipcode.');
        
        // do something here
    }
}

与接口(interfaces)不同的是,使用特征(traits)只能声明方法签名,而不能声明其函数代码。此外,要使用接口,您需要遵循层次结构,使用 implements。但这并不适用于特征。

它完全不同!


我认为这是一个不好的 trait 示例。to_integer 更可能被包含在 IntegerCast 接口中,因为没有根本上类似的方法可以(智能地)将类转换为整数。 - Matthew
5
别把 "to_integer" 当真,它只是一个例子。就像 "Hello, World" 或者 "example.com" 一样,只是为了举例说明。 - J. Bruni
2
这个工具包 trait 提供了什么好处,而独立的实用类则不能?你可以使用 $this->toolkit = new Toolkit(); 代替 use Toolkit,或者我错过了 trait 本身的一些好处吗? - Anthony
@Anthony 在你的 Something 容器中,你执行了 if(!$something->do_something('foo')) var_dump($something->errors); - TheRealChx101

28

对于初学者来说,以上答案可能有些难以理解,以下是最简单的理解方法:

特征

trait SayWorld {
    public function sayHello() {
        echo 'World!';
    }
}

如果你想在其他类中使用sayHello函数,而不必重新创建整个函数,那么你可以使用traits。

class MyClass{
  use SayWorld;

}

$o = new MyClass();
$o->sayHello();

酷毙了!

在特质中,不仅可以使用函数,还可以使用变量、常量等任何东西。另外,你还可以使用多个特质:use SayWorld, AnotherTraits;

接口

  interface SayWorld {
     public function sayHello();
  }

  class MyClass implements SayWorld { 
     public function sayHello() {
        echo 'World!';
     }
}

这就是接口与特质之间的不同之处:您必须在已实现的类中重新创建接口中的所有内容。接口没有实现,只能有函数和常量,不能有变量。

希望对您有所帮助!


8

6
常用的描述Trait的隐喻是Trait是带有实现的接口。在大多数情况下,这是思考Trait的好方法,但是两者之间存在一些微妙的差异。首先,instanceof运算符不能与Traits一起使用(即Trait不是一个真正的对象),因此您不能使用它来查看类是否具有某个Trait(或查看两个否则不相关的类是否共享Trait)。这就是它被称为水平代码重用构造的原因。现在在PHP中有一些函数可以让您获取类使用的所有Traits的列表,但Trait继承意味着您需要进行递归检查以可靠地检查某个类是否具有特定的Trait(在PHP文档页面上有示例代码)。但是,它肯定不像instanceof那样简单明了,在我看来,这是使PHP更好的一个功能。另外,抽象类仍然是类,因此它们无法解决与多重继承相关的代码重用问题。请记住,您只能扩展一个类(真实或抽象),但可以实现多个接口。我发现Trait和接口结合使用非常好,可以创建伪多重继承。例如:
class SlidingDoor extends Door implements IKeyed  
{  
    use KeyedTrait;  
    [...] // Generally not a lot else goes here since it's all in the trait  
}

这样做意味着您可以使用instanceof来确定特定的门对象是否为 Keyed。您知道您将获得一组一致的方法,所有代码都在使用 KeyedTrait 的所有类中的一个地方。


答案的最后一部分当然是@rdlowrey在他的帖子中“特征”下的最后三段中更详细地阐述的内容;我只是觉得一个非常简单的骨架代码片段会有助于说明它。 - Jon Marnock
我认为在使用特质时,最好的面向对象方式是尽可能使用接口。如果有多个子类实现了同一种接口的代码,并且你无法将该代码移动到它们的(抽象)超类中,则可以使用特质来实现。 - Johannes Reiners

4

你可以把 trait 看作是自动化的“复制粘贴”代码。

使用 trait 是危险的,因为在执行之前无法知道它的作用。

然而,由于缺乏类似继承的限制,trait 更加灵活。

例如,traits 可以注入一个检查类中某个方法或属性是否存在的方法。这是一篇关于此主题的好文章(但是是法语)

对于那些能够阅读法语的人来说,GNU/Linux Magazine HS 54 上有一篇关于此主题的文章。


仍然不明白特质(traits)与带有默认实现的接口(interfaces)之间的区别。 - denis631
@denis631 你可以将Traits视为代码片段,将接口视为签名合约。如果有帮助的话,你可以将其看作是一个包含任何内容的类的非正式部分。如果有帮助,请告诉我。 - Benj
1
我注意到 PHP traits 可以被视为在编译时扩展的宏,或者说是将代码片段与键名进行别名化。然而,Rust traits 似乎有所不同(或许我错了)。但既然它们都带有 trait 这个单词,我会认为它们是相同的,意味着相同的概念。Rust traits 链接:https://doc.rust-lang.org/rust-by-example/trait.html。 - denis631

2
其他回答已经很好地解释了接口和特质之间的区别。我将专注于一个有用的现实世界示例,特别是演示特质可以使用实例变量的情况 - 使您可以通过最小的样板代码向类添加行为。
同样,像其他人提到的那样,特质与接口配对很好,允许接口指定行为契约,而特质则履行实现。
在某些代码库中,向类添加事件发布/订阅功能可能是常见的场景。有3种常见的解决方案:
1.定义带有事件pub/sub代码的基类,然后想要提供事件的类可以扩展它以获得这些功能。
2.定义一个带有事件pub/sub代码的类,然后其他想要提供事件的类可以通过组合使用它,定义自己的方法来包装组合对象,将方法调用代理给它。
3.定义一个带有事件pub/sub代码的特质,然后其他想要提供事件的类可以使用该特质,即导入它,以获得这些功能。
每个方案的效果如何?
1.不起作用。除非你意识到你已经扩展了其他内容,否则它会一直存在。我不会展示这个例子,因为使用继承这样做有多么具有限制性应该是显而易见的。
2和3都很好用。我将展示一个突出一些差异的例子。
首先,两个示例之间将相同的代码:一个接口
interface Observable {
    function addEventListener($eventName, callable $listener);
    function removeEventListener($eventName, callable $listener);
    function removeAllEventListeners($eventName);
}

以下是一些代码示例:

$auction = new Auction();

// Add a listener, so we know when we get a bid.
$auction->addEventListener('bid', function($bidderName, $bidAmount){
    echo "Got a bid of $bidAmount from $bidderName\n";
});

// Mock some bids.
foreach (['Moe', 'Curly', 'Larry'] as $name) {
    $auction->addBid($name, rand());
}

好的,现在让我们展示一下使用traits时Auction类的实现会有何不同。

首先,这是使用组合的方式(方法#2)的实现:

class EventEmitter {
    private $eventListenersByName = [];

    function addEventListener($eventName, callable $listener) {
        $this->eventListenersByName[$eventName][] = $listener;
    }

    function removeEventListener($eventName, callable $listener) {
        $this->eventListenersByName[$eventName] = array_filter($this->eventListenersByName[$eventName], function($existingListener) use ($listener) {
            return $existingListener === $listener;
        });
    }

    function removeAllEventListeners($eventName) {
        $this->eventListenersByName[$eventName] = [];
    }

    function triggerEvent($eventName, array $eventArgs) {
        foreach ($this->eventListenersByName[$eventName] as $listener) {
            call_user_func_array($listener, $eventArgs);
        }
    }
}

class Auction implements Observable {
    private $eventEmitter;

    public function __construct() {
        $this->eventEmitter = new EventEmitter();
    }

    function addBid($bidderName, $bidAmount) {
        $this->eventEmitter->triggerEvent('bid', [$bidderName, $bidAmount]);
    }

    function addEventListener($eventName, callable $listener) {
        $this->eventEmitter->addEventListener($eventName, $listener);
    }

    function removeEventListener($eventName, callable $listener) {
        $this->eventEmitter->removeEventListener($eventName, $listener);
    }

    function removeAllEventListeners($eventName) {
        $this->eventEmitter->removeAllEventListeners($eventName);
    }
}

以下是第三种(特征)的示例:

这是第三种(特征)的示例:

trait EventEmitterTrait {
    private $eventListenersByName = [];

    function addEventListener($eventName, callable $listener) {
        $this->eventListenersByName[$eventName][] = $listener;
    }

    function removeEventListener($eventName, callable $listener) {
        $this->eventListenersByName[$eventName] = array_filter($this->eventListenersByName[$eventName], function($existingListener) use ($listener) {
            return $existingListener === $listener;
        });
    }

    function removeAllEventListeners($eventName) {
        $this->eventListenersByName[$eventName] = [];
    }

    protected function triggerEvent($eventName, array $eventArgs) {
        foreach ($this->eventListenersByName[$eventName] as $listener) {
            call_user_func_array($listener, $eventArgs);
        }
    }
}

class Auction implements Observable {
    use EventEmitterTrait;

    function addBid($bidderName, $bidAmount) {
        $this->triggerEvent('bid', [$bidderName, $bidAmount]);
    }
}

请注意,EventEmitterTrait 中的代码与 EventEmitter 类中的代码完全相同,除了 trait 将 triggerEvent() 方法声明为受保护的。因此,你需要查看的唯一区别是 Auction 类的实现
而这个区别很大。使用组合时,我们得到了一个很好的解决方案,允许我们通过尽可能多地使用 EventEmitter 来重用它。但是,主要缺点是我们需要编写和维护大量样板代码,因为对于在 Observable 接口中定义的每个方法,我们都需要实现它并编写令人厌烦的样板代码,只需将参数转发到我们所组合的 EventEmitter 对象中的相应方法即可。在该示例中使用 trait 可以帮助我们避免这种情况,从而帮助我们减少样板代码并提高可维护性
然而,在某些情况下,您可能不想让 Auction 类实现完整的 Observable 接口 - 也许您只想公开 1 或 2 个方法,或者甚至根本不想公开方法以便定义自己的方法签名。在这种情况下,您可能仍然更喜欢组合方法。
但是,在大多数情况下,trait 非常有吸引力,特别是如果接口具有许多方法,则会导致您编写大量样板代码。
* 你实际上可以两者兼备 - 定义 EventEmitter 类以便在需要使用组合时使用它,并定义 EventEmitterTrait trait,使用 trait 中的 EventEmitter 类实现 :)

2
如果您知道英语并且知道“trait”的含义,那么它正是其名称所表示的内容。它是一组不带类的方法和属性,您可以通过键入“use”将其附加到现有类上。
基本上,您可以将其与单个变量进行比较。闭包函数可以从范围之外“使用”这些变量,并以此获得其内部值。它们非常强大,可用于各种应用中。如果使用了trait,则相同的情况也会发生。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接