Skip to content

PHP 8.0+ 新字符串函数

概述

PHP 8.0 引入了三个新字符串函数:str_contains()str_starts_with()str_ends_with()。这些函数解决了长期以来使用 strpos 进行子串检查时的可读性和安全性问题,已经成为现代 PHP 开发中字符串操作的首选。

基础概念

新旧写法对比

操作旧写法(PHP < 8.0)新写法(PHP 8.0+)
包含检查strpos($str, $needle) !== falsestr_contains($str, $needle)
前缀检查strpos($str, $needle) === 0str_starts_with($str, $needle)
后缀检查substr($str, -strlen($needle)) === $needlestr_ends_with($str, $needle)

为什么需要新函数

旧的 strpos 检查方式有两个问题:(1) 可读性差,需要理解 !== false 的含义;(2) 容易出错,新手常写成 == 或忘记处理返回值 0。新函数返回布尔值,语义清晰,不会出错。

语法与代码

str_contains — 包含检查

php
<?php

declare(strict_types=1);

// str_contains(string $haystack, string $needle): bool
// 检查 $haystack 是否包含 $needle,区分大小写

$email = 'alice@example.com';
echo str_contains($email, '@');         // true
echo str_contains($email, 'example');   // true
echo str_contains($email, 'EXAMPLE');   // false(区分大小写)

// 空字符串始终返回 true
echo str_contains('Hello', '');          // true
echo str_contains('', '');               // true

// 替代旧的 strpos 写法
$old = strpos($email, '@') !== false;   // 旧写法
$new = str_contains($email, '@');       // 新写法,等价且更清晰

str_starts_with — 前缀检查

php
<?php

declare(strict_types=1);

// str_starts_with(string $haystack, string $needle): bool
// 检查 $haystack 是否以 $needle 开头

$url = 'https://example.com/path';
echo str_starts_with($url, 'https://');  // true
echo str_starts_with($url, 'http://');   // false

// 常见应用场景:协议检查
function isHttpsUrl(string $url): bool
{
    return str_starts_with($url, 'https://');
}

// 命名空间前缀检查
$class = 'App\\Services\\UserService';
echo str_starts_with($class, 'App\\');     // true
echo str_starts_with($class, 'App\\Services\\');  // true

// 替代旧的 strpos 写法
$old = strpos($url, 'https://') === 0;    // 旧写法
$new = str_starts_with($url, 'https://');  // 新写法

str_ends_with — 后缀检查

php
<?php

declare(strict_types=1);

// str_ends_with(string $haystack, string $needle): bool
// 检查 $haystack 是否以 $needle 结尾

$filename = 'photo.jpg';
echo str_ends_with($filename, '.jpg');   // true
echo str_ends_with($filename, '.png');   // false

// 常见应用场景:文件扩展名检查
function isImageFile(string $filename): bool
{
    return str_ends_with(strtolower($filename), '.jpg')
        || str_ends_with(strtolower($filename), '.png')
        || str_ends_with(strtolower($filename), '.gif')
        || str_ends_with(strtolower($filename), '.webp');
}

// 命名空间后缀检查
$class = 'Controller\\AdminController';
echo str_ends_with($class, 'Controller'); // true

// 替代旧的 substr 写法
$needle = '.jpg';
$old = substr($filename, -strlen($needle)) === $needle;  // 旧写法
$new = str_ends_with($filename, $needle);                 // 新写法

大小写不敏感的版本

PHP 核心没有直接提供大小写不敏感的版本,但可以通过辅助函数实现。

php
<?php

declare(strict_types=1);

// 大小写不敏感的包含检查
function str_icontains(string $haystack, string $needle): bool
{
    return str_contains(strtolower($haystack), strtolower($needle));
}

// 大小写不敏感的前缀检查
function str_istarts_with(string $haystack, string $needle): bool
{
    return str_starts_with(strtolower($haystack), strtolower($needle));
}

// 大小写不敏感的后缀检查
function str_iends_with(string $haystack, string $needle): bool
{
    return str_ends_with(strtolower($haystack), strtolower($needle));
}

// 使用示例
echo str_istarts_with('HTTPS://example.com', 'https://');  // true
echo str_iends_with('photo.JPG', '.jpg');                   // true

详细说明

与 strpos 的性能对比

str_contains 等新函数在内部实现上与 strpos 类似,性能差异可以忽略不计。新函数的主要优势在于可读性和安全性。

php
<?php

declare(strict_types=1);

// 旧写法容易出错的各种场景
$str = 'Hello World';

// 错误 1:使用 == 比较(0 == false 为 true,但 0 !== false)
if (strpos($str, 'Hello') == false) {
    // 这里不会执行(strpos 返回 0,0 != false)
}

// 错误 2:忘记 ===
if (strpos($str, 'World')) {
    // 正确执行(strpos 返回 6,truthy)
}
if (strpos($str, 'Hello')) {
    // 不执行!(strpos 返回 0,falsy)
}

// 新写法永远正确
if (str_contains($str, 'Hello')) {
    // 正确执行
}
if (str_contains($str, 'World')) {
    // 正确执行
}

函数签名

php
<?php

// PHP 8.0+ 函数签名
str_contains(string $haystack, string $needle): bool
str_starts_with(string $haystack, string $needle): bool
str_ends_with(string $haystack, string $needle): bool

// 注意:
// - 两个参数都是 string 类型(PHP 8.0+ 严格类型)
// - 返回值始终是 bool
// - 空的 $needle 始终返回 true
// - PHP 8.0 之前传入非字符串参数会产生 TypeError

实战示例

路由匹配系统

php
<?php

declare(strict_types=1);

class Router
{
    private array $routes = [];

    public function get(string $pattern, callable $handler): void
    {
        $this->routes['GET'][] = ['pattern' => $pattern, 'handler' => $handler];
    }

    public function dispatch(string $method, string $uri): mixed
    {
        foreach ($this->routes[$method] ?? [] as $route) {
            if (str_starts_with($uri, $route['pattern'])) {
                return ($route['handler'])($uri);
            }
        }
        return null;
    }
}

$router = new Router();
$router->get('/api/users', fn(string $uri) => ['handler' => 'userList', 'uri' => $uri]);
$router->get('/api/posts', fn(string $uri) => ['handler' => 'postList', 'uri' => $uri]);

$result = $router->dispatch('GET', '/api/users/123');
print_r($result);

CI/CD 分支保护(str_ends_with 实战)

php
<?php

declare(strict_types=1);

class BranchValidator
{
    private const PROTECTED_SUFFIXES = ['-main', '-production', '-staging'];
    private const ALLOWED_PREFIXES = ['feature/', 'bugfix/', 'hotfix/', 'release/'];

    public static function isProtectedBranch(string $branchName): bool
    {
        foreach (self::PROTECTED_SUFFIXES as $suffix) {
            if (str_ends_with($branchName, $suffix)) {
                return true;
            }
        }
        return false;
    }

    public static function isValidFeatureBranch(string $branchName): bool
    {
        foreach (self::ALLOWED_PREFIXES as $prefix) {
            if (str_starts_with($branchName, $prefix)) {
                return true;
            }
        }
        return false;
    }

    public static function validate(string $branchName): array
    {
        $errors = [];

        if (self::isProtectedBranch($branchName)) {
            $errors[] = "分支 {$branchName} 受保护,不能直接推送";
        }

        if (!self::isValidFeatureBranch($branchName) && !self::isProtectedBranch($branchName)) {
            $errors[] = "分支名必须以以下前缀之一开头: " . implode(', ', self::ALLOWED_PREFIXES);
        }

        return $errors;
    }
}

print_r(BranchValidator::isProtectedBranch('project-main'));    // true
print_r(BranchValidator::isValidFeatureBranch('feature/login')); // true
print_r(BranchValidator::validate('random-branch'));              // ['错误信息...']

文件类型过滤器

php
<?php

declare(strict_types=1);

class FileFilter
{
    /**
     * @param string[] $allowedExtensions 允许的扩展名(不含点号)
     */
    public static function filterByExtension(array $files, array $allowedExtensions): array
    {
        return array_filter($files, function (string $file) use ($allowedExtensions): bool {
            $lower = strtolower($file);
            foreach ($allowedExtensions as $ext) {
                if (str_ends_with($lower, '.' . strtolower($ext))) {
                    return true;
                }
            }
            return false;
        });
    }

    public static function filterByPrefix(array $files, string $prefix): array
    {
        return array_filter(
            $files,
            fn(string $file): bool => str_starts_with(basename($file), $prefix)
        );
    }
}

$files = ['photo.jpg', 'document.pdf', 'image.png', 'data.csv', 'picture.jpeg'];
$images = FileFilter::filterByExtension($files, ['jpg', 'png', 'jpeg', 'gif', 'webp']);
print_r(array_values($images));
// ['photo.jpg', 'image.png', 'picture.jpeg']

输入验证与清洗

php
<?php

declare(strict_types=1);

class InputValidator
{
    public static function isValidEmail(string $email): bool
    {
        return str_contains($email, '@') && str_contains($email, '.');
    }

    public static function isInternalUrl(string $url): bool
    {
        return str_starts_with($url, '/internal/')
            || str_starts_with($url, '/admin/');
    }

    public static function hasDangerousContent(string $input): bool
    {
        $patterns = ['<script', 'javascript:', 'data:', 'onerror=', 'onload='];

        foreach ($patterns as $pattern) {
            if (str_icontains($input, $pattern)) {
                return true;
            }
        }
        return false;
    }
}

// 辅助函数:大小写不敏感的包含检查
function str_icontains(string $haystack, string $needle): bool
{
    return str_contains(strtolower($haystack), strtolower($needle));
}

str_contains 的内部实现

php
<?php

declare(strict_types=1);

// str_contains 的 polyfill(PHP < 8.0)
if (!function_exists('str_contains')) {
    function str_contains(string $haystack, string $needle): bool
    {
        return strpos($haystack, $needle) !== false;
    }
}

if (!function_exists('str_starts_with')) {
    function str_starts_with(string $haystack, string $needle): bool
    {
        return strncmp($haystack, $needle, strlen($needle)) === 0;
    }
}

if (!function_exists('str_ends_with')) {
    function str_ends_with(string $haystack, string $needle): bool
    {
        $length = strlen($needle);
        if ($length === 0) {
            return true;
        }
        return substr($haystack, -$length) === $needle;
    }
}

更多的前缀/后缀检查场景

php
<?php

declare(strict_types=1);

// 命名空间检测
function isInNamespace(string $class, string $namespace): bool
{
    return str_starts_with($class, $namespace . '\\');
}

// MIME 类型检测
function isImageMimeType(string $mime): bool
{
    return str_starts_with($mime, 'image/');
}

// 版本号比较前缀
function isStableVersion(string $version): bool
{
    return !str_contains($version, '-') && !str_contains($version, '+');
}

// 路径规范化检测
function isAbsolutePath(string $path): bool
{
    return str_starts_with($path, '/') || str_starts_with($path, 'C:\\');
}

// API 路由前缀匹配
class RoutePrefix
{
    private const PREFIXES = [
        'api' => '/api/',
        'admin' => '/admin/',
        'internal' => '/internal/',
    ];

    public static function match(string $uri): ?string
    {
        foreach (self::PREFIXES as $name => $prefix) {
            if (str_starts_with($uri, $prefix)) {
                return $name;
            }
        }
        return null;
    }
}

echo RoutePrefix::match('/api/users/123');     // api
echo RoutePrefix::match('/admin/dashboard');     // admin
echo RoutePrefix::match('/public/index.html');   // null

输入验证实战

php
<?php

declare(strict_types=1);

class StringValidator
{
    // 验证密码策略
    public static function checkPasswordPolicy(string $password): array
    {
        $errors = [];

        if (strlen($password) < 8) {
            $errors[] = '密码至少 8 个字符';
        }
        if (!str_contains($password, ' ')) {
            // 不允许空格
        }
        if (!preg_match('/[A-Z]/', $password)) {
            $errors[] = '需要至少一个大写字母';
        }
        if (!preg_match('/[0-9]/', $password)) {
            $errors[] = '需要至少一个数字';
        }

        return $errors;
    }

    // 验证域名
    public static function isDomain(string $value): bool
    {
        if (!str_contains($value, '.')) {
            return false;
        }
        $parts = explode('.', $value);
        $tld = strtolower(end($parts));
        return str_ends_with($value, '.' . $tld);
    }

    // 检查是否为内部 API
    public static function isInternalApi(string $url): bool
    {
        $internalHosts = ['localhost', '127.0.0.1', 'internal.example.com'];

        foreach ($internalHosts as $host) {
            if (str_starts_with($url, "http://{$host}") ||
                str_starts_with($url, "https://{$host}")) {
                return true;
            }
        }
        return false;
    }
}

性能说明

str_containsstr_starts_withstr_ends_with 在内部使用了优化的实现。对于简单的字符串检查,它们的性能与 strpos 几乎相同,但代码更安全、更可读。

注意事项

空字符串行为

三个新函数中,当 $needle 为空字符串时,始终返回 true。这与 strpos 的行为一致(strpos($str, '') 返回 0),但可能在逻辑判断中产生意外结果。建议在调用前检查 $needle 是否为空。

PHP 版本要求

str_containsstr_starts_withstr_ends_with 需要 PHP 8.0+。如果需要在旧版本中使用,可以通过 polyfill 实现,或继续使用 strpos / substr 模式。

大小写敏感

这三个函数都是大小写敏感的。如果需要大小写不敏感的比较,需要先将两个参数都转换为小写,或使用自定义辅助函数。

最佳实践

  1. PHP 8.0+ 项目优先使用新函数str_contains / str_starts_with / str_ends_with 是字符串检查的首选
  2. 避免旧模式:不再使用 strpos($str, $needle) !== falsestrpos($str, $needle) === 0substr(...) === $needle
  3. 封装大小写不敏感版本:在项目中创建 str_icontains 等辅助函数复用
  4. 检查空 needle:在可能传入空字符串的场景中,提前检查
  5. 结合类型声明使用:配合 strict_types=1 确保参数类型正确

参考链接