Skip to content

Stringable 接口

概述

Stringable 接口是 PHP 8.0 引入的新接口,用于标识一个类可以隐式转换为字符串。当一个类实现了 __toString() 魔术方法时,PHP 会自动实现 Stringable 接口,无需显式声明 implements Stringable

该接口的核心价值在于类型系统:函数参数可以使用 string|Stringable 联合类型来接受字符串或任何可转换为字符串的对象。

版本说明

Stringable 接口在 PHP 8.0 中引入。PHP 8.0+ 中任何定义了 __toString() 方法的类都会自动实现该接口。PHP 7.4 及以下版本不存在此接口。

基础概念

接口定义

php
<?php

declare(strict_types=1);

// Stringable 接口定义
// interface Stringable
// {
//     public function __toString(): string;
// }

自动实现机制

在 PHP 8.0+ 中,只要类中定义了 __toString() 方法,PHP 引擎会自动让该类实现 Stringable 接口,即使没有显式声明。

php
<?php

declare(strict_types=1);

class Money
{
    public function __construct(
        private readonly int $amount,
        private readonly string $currency = 'CNY'
    ) {}

    public function __toString(): string
    {
        return sprintf('%s %.2f', $this->currency, $this->amount / 100);
    }
}

$money = new Money(999, 'USD');

// 自动实现了 Stringable 接口
var_dump($money instanceof Stringable);  // bool(true)
echo $money;                             // USD 9.99

语法与代码

类型声明 string|Stringable

Stringable 的主要用途是在函数参数中进行类型约束,允许接受字符串或任何可字符串化的对象。

php
<?php

declare(strict_types=1);

function writeLog(string|Stringable $message): void
{
    $text = (string) $message;
    $timestamp = date('Y-m-d H:i:s');
    echo "[{$timestamp}] {$text}\n";
}

class User
{
    public function __construct(
        private readonly string $name,
        private readonly string $email
    ) {}

    public function __toString(): string
    {
        return "{$this->name} <{$this->email}>";
    }
}

writeLog('系统启动');                           // 传入字符串
writeLog(new User('张三', 'zhangsan@mail.com')); // 传入 Stringable 对象

显式实现 Stringable(PHP 8.0+)

php
<?php

declare(strict_types=1);

// 虽然会自动实现,但显式声明更清晰
class Uuid implements Stringable
{
    private const PATTERN = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i';

    private function __construct(
        private readonly string $value
    ) {
        if (!preg_match(self::PATTERN, $value)) {
            throw new InvalidArgumentException("无效的 UUID 格式: {$value}");
        }
    }

    public static function generate(): self
    {
        $data = random_bytes(16);
        $data[6] = chr(ord($data[6]) & 0x0f | 0x40);
        $data[8] = chr(ord($data[8]) & 0x3f | 0x80);

        return new self(sprintf(
            '%s-%s-%s-%s-%s',
            bin2hex(substr($data, 0, 4)),
            bin2hex(substr($data, 4, 4)),
            bin2hex(substr($data, 8, 4)),
            bin2hex(substr($data, 12, 2)),
            bin2hex(substr($data, 14, 2))
        ));
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

$id = Uuid::generate();
echo $id;  // 例如: 550e8400-e29b-41d4-a716-446655440000

__toString() 中的异常处理

php
<?php

declare(strict_types=1);

class HtmlElement implements Stringable
{
    public function __construct(
        private readonly string $tag,
        private readonly string $content = '',
        private readonly array $attributes = []
    ) {}

    public function __toString(): string
    {
        $attrs = '';
        foreach ($this->attributes as $name => $value) {
            $escaped = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
            $attrs .= " {$name}=\"{$escaped}\"";
        }

        if (in_array($this->tag, ['br', 'hr', 'img', 'input'], true)) {
            return "<{$this->tag}{$attrs} />";
        }

        return "<{$this->tag}{$attrs}>{$this->content}</{$this->tag}>";
    }
}

$div = new HtmlElement('div', 'Hello World', ['class' => 'container', 'id' => 'main']);
echo $div;  // <div class="container" id="main">Hello World</div>

$hr = new HtmlElement('hr', '', ['class' => 'divider']);
echo $hr;   // <hr class="divider" />

__toString() 中的异常

PHP 7.4 以前,__toString() 方法不允许抛出异常(如果抛出会导致致命错误)。PHP 7.4+ 允许在 __toString() 中抛出异常,但如果是通过字符串拼接等方式隐式调用,异常可能无法被捕获。PHP 8.0+ 已修复此限制,可以安全地在 __toString() 中抛出异常。

详细说明

Stringable 在模板引擎中的应用

php
<?php

declare(strict_types=1);

class SafeHtml implements Stringable
{
    private string $html;

    public function __construct(string $html)
    {
        // 构造时标记为安全(假定已做净化处理)
        $this->html = $html;
    }

    public function __toString(): string
    {
        return $this->html;
    }

    public static function escape(string $raw): self
    {
        return new self(htmlspecialchars($raw, ENT_QUOTES, 'UTF-8'));
    }
}

class TemplateRenderer
{
    public function render(string|Stringable $title, string|Stringable $body): string
    {
        return <<<HTML
        <!DOCTYPE html>
        <html>
        <head><title>{$title}</title></head>
        <body>{$body}</body>
        </html>
        HTML;
    }
}

$renderer = new TemplateRenderer();
$html = $renderer->render(
    SafeHtml::escape('<script>alert("xss")</script>'),
    SafeHtml::escape('<b>Welcome</b>')
);
echo $html;
// 标题和内容都会被转义

与旧版 PHP 的兼容

php
<?php

declare(strict_types=1);

// PHP 7.4 及以下不存在 Stringable 接口
// 跨版本兼容方案
if (interface_exists('Stringable')) {
    // PHP 8.0+
    abstract class AbstractStringable implements Stringable
    {
        abstract public function __toString(): string;
    }
} else {
    // PHP 7.4 及以下
    abstract class AbstractStringable
    {
        abstract public function __toString(): string;
    }
}

字符串插值中的 Stringable

php
<?php

declare(strict_types=1);

class IpAddress implements Stringable
{
    public function __construct(private readonly string $address)
    {
        if (!filter_var($address, FILTER_VALIDATE_IP)) {
            throw new InvalidArgumentException("无效的 IP 地址: {$address}");
        }
    }

    public function __toString(): string
    {
        return $this->address;
    }

    public function isPrivate(): bool
    {
        return filter_var(
            $this->address,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE
        ) === false;
    }
}

$ip = new IpAddress('192.168.1.1');
echo "服务器 IP: {$ip}";  // 自动调用 __toString()
// 输出: 服务器 IP: 192.168.1.1

// 在 sprintf 中同样自动转换
printf("连接到 %s (私有: %s)", $ip, $ip->isPrivate() ? '是' : '否');

实战示例

金额对象

php
<?php

declare(strict_types=1);

class Amount implements Stringable
{
    private const CURRENCY_SYMBOLS = [
        'CNY' => '¥',
        'USD' => '$',
        'EUR' => '€',
        'GBP' => '£',
        'JPY' => '¥',
    ];

    public function __construct(
        private readonly int $cents,
        private readonly string $currency = 'CNY'
    ) {
        if ($this->cents < 0) {
            throw new InvalidArgumentException('金额不能为负数');
        }
    }

    public static function fromYuan(float $yuan, string $currency = 'CNY'): self
    {
        return new self((int) round($yuan * 100), $currency);
    }

    public static function fromDollar(float $dollar): self
    {
        return self::fromYuan($dollar, 'USD');
    }

    public function cents(): int
    {
        return $this->cents;
    }

    public function currency(): string
    {
        return $this->currency;
    }

    public function __toString(): string
    {
        $symbol = self::CURRENCY_SYMBOLS[$this->currency] ?? $this->currency;
        $formatted = number_format($this->cents / 100, 2, '.', ',');

        return "{$symbol}{$formatted}";
    }

    public function add(Amount $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException('不能对不同币种进行运算');
        }
        return new self($this->cents + $other->cents, $this->currency);
    }
}

$price = Amount::fromYuan(99.9);
$shipping = Amount::fromYuan(10.0);
$total = $price->add($shipping);

echo "商品: {$price}, 运费: {$shipping}, 合计: {$total}";
// 商品: ¥99.90, 运费: ¥10.00, 合计: ¥109.90

注意事项

__toString() 触发场景

php
<?php

declare(strict_types=1);

class Demo implements Stringable
{
    public function __toString(): string
    {
        echo "__toString() 被调用\n";
        return "Demo对象";
    }
}

$obj = new Demo();

echo $obj;              // 触发 __toString()
$str = (string) $obj;   // 显式触发
"$obj";                 // 字符串插值触发
sprintf("%s", $obj);   // sprintf 触发
echo $obj . ' suffix';  // 字符串拼接触发

// 以下不触发
strlen($obj);           // 不会自动转换,需要 strlen((string) $obj)
var_dump($obj);         // 不会调用 __toString()

不能在 __toString() 中抛出异常(PHP 7.4 限制)

php
<?php

declare(strict_types=1);

// PHP 7.4: __toString() 中抛出异常是致命错误
// PHP 8.0+: 允许在 __toString() 中抛出异常
class SafeStringable implements Stringable
{
    public function __toString(): string
    {
        try {
            $result = $this->doRiskyOperation();
            return $result;
        } catch (Throwable $e) {
            // PHP 7.4 兼容方案:捕获异常并返回安全字符串
            return '[错误: ' . $e->getMessage() . ']';
        }
    }

    private function doRiskyOperation(): string
    {
        // 可能失败的操作
        return '操作结果';
    }
}

最佳实践

  1. 显式声明 implements Stringable:虽然会自动实现,但显式声明代码更清晰,IDE 提示更好
  2. __toString() 返回有意义的表示:不应返回调试信息或空字符串,应返回面向用户的可读字符串
  3. 保持 __toString() 简单:避免在 __toString() 中执行复杂逻辑或 I/O 操作
  4. 使用 string|Stringable 类型约束:让函数/方法同时接受字符串和可字符串化对象
  5. 注意 __toString() 的隐式调用:在字符串拼接和插值中会自动触发
php
<?php

declare(strict_types=1);

// 参数类型约束的最佳实践
function logMessage(string|Stringable $message, string $level = 'info'): void
{
    $text = is_object($message) ? (string) $message : $message;

    $line = sprintf(
        "[%s] [%s] %s",
        date('Y-m-d H:i:s'),
        strtoupper($level),
        $text
    );

    error_log($line);
}

// 返回值类型约束
function renderTitle(string|Stringable $raw): string
{
    // 始终返回 string 类型
    $str = (string) $raw;
    return htmlspecialchars($str, ENT_QUOTES, 'UTF-8');
}

参考链接