php 可变函数使用小结

前言

可变函数(variable functions)指的是把函数名存进变量,再用这个变量加括号调用:

php 复制代码
$fn = 'strlen';

echo $fn('hello'); // 5

它解决的是「调用哪个函数直到运行时才确定」这个问题------插件注册、命令分发、表单处理器映射、回调注册表,本质都是同一件事。相对于写一长串 if / elseif 或 switch,可变函数让「名字到实现」的映射变成一张表,增删一项不用改控制流。

关于它有三个常见误解。第一,以为「函数名当字符串用」是万能语法糖 ,实际上 echo、isset()、empty()、unset()、include、require 这类是语言构造(language construct)而非函数 ,不能用可变函数的写法调用。第二,以为变量函数是安全的中性语法 ------恰恰相反,当函数名来自外部输入时,它等价于把「执行什么代码」的选择权交出去,这是很多同类漏洞的源头。第三,以为 is_callable() 和 function_exists() 可以互相替代 ,其实两者回答的是不同问题,前者还会被 __call() 魔术方法「骗过」。

本文先把机制讲清楚(包括它为什么对语言构造无效、字符串形式的名字如何解析),再把方法形式的可变调用和 Closure::fromCallable() / 首类可调用语法这些现代写法串起来,最后给出安全的分发模式。

一、机制:名字是怎么被查表的

可变函数的语义可以概括成两步:先取出变量的值,把这个值当函数名,去当前符号表里查;查到就调用,查不到就报「未定义函数」的致命错误。因为是「先取值」,所以变量的来源可以是任何表达式:

php 复制代码
<?php // 适用于 PHP 7.4+

declare(strict_types=1);



function hello(string $name): string

{

    return "hello {$name}";

}



$name = 'hello';

echo $name('world'), PHP_EOL;          // hello world



// 数组元素同样可以作为函数名的来源

$map = ['greet' => 'hello'];

echo $map['greet']('bob'), PHP_EOL;    // hello bob

字符串形式的函数名总是按完全限定名解析 ,不受当前命名空间影响。也就是说,在 namespace App; 里写 $fn = 'hello'; $fn(); 找的是全局的 \hello,不是 \App\hello。要调用命名空间里的函数,必须把完整名字写进字符串:

php 复制代码
<?php // 适用于 PHP 7.4+

namespace App;



function hello(string $name): string { return "app hello {$name}"; }



$fn = __NAMESPACE__ . '\\hello';       // App\hello

echo $fn('x'), PHP_EOL;                // app hello x



$fn2 = 'App\\hello';                   // 效果相同,写死命名空间

echo $fn2('y'), PHP_EOL;               // app hello y

这一点和「直接写函数调用」的规则相反 :源码里的 hello('x') 会先在当前命名空间里找,找不到才回退到全局;而字符串形式的 'hello' 里没有命名空间,就是全局的那个。

语言构造不能用可变函数调用 ,这是新手最容易踩的一条。echo、print、unset()、isset()、empty()、include、require、list() 都不是函数,把它们存进变量再加括号只会得到「未定义函数」的错误。需要用的时候,包一层闭包即可:

php 复制代码
<?php // 适用于 PHP 7.4+

// ❌ $f = 'echo'; $f('hi');   // Fatal error: Uncaught Error: Call to undefined function echo()



// ✅ 用闭包包一层,就把语言构造变成了真正的函数

$f = static function (string $v): void { echo $v; };

$f('hi');

echo PHP_EOL;



$isSet = static fn (mixed $v): bool => isset($v);

var_dump($isSet(null));  // bool(false)

static fn 是 PHP 7.4 起的箭头函数语法,PHP 7.3 及以下请写 static function (mixed $v): bool { return isset($v); },并且把 mixed 类型去掉(mixed 也是 PHP 8.0 才有的类型)。

二、方法形式的可变调用

方法可以按同样的思路动态调用,写法有三种,语义各不相同:

写法 调用的东西 说明

|-------------------|-------------------------|--------------------|
| $obj->$method() | 实例方法,$this 绑定到 $obj | 方法不存在时走 __call() |

|------------------|-----------------------------|---------------------|
| Foo::$method() | 手册描述为「按 $method 的值调用静态方法」 | 各版本实际行为有出入,不要依赖 |

|-------------------|---------------|-----------|
| $obj->{$expr}() | 先用表达式算出方法名再调用 | 名字是表达式时用它 |

第三种写法是唯一能在有歧义时明确表达意图的形式。手册里 Foo::$method() 被描述为「函数调用比静态属性运算符结合更紧」,即按静态方法名解析;但有使用者报告在 PHP 7.4 上实测结果与手册示例不一致。因此要动态调用静态方法,请显式写花括号:

php 复制代码
<?php // 适用于 PHP 8.0+

declare(strict_types=1);



final class Calc

{

    public static function add(int $a, int $b): int  { return $a + $b; }

    public static function mul(int $a, int $b): int  { return $a * $b; }

}



$op = 'add';



echo Calc::{$op}(2, 3), PHP_EOL;   // 5,意图明确,不依赖解析顺序



// ❌ 下面这种写法请避免依赖:手册与实际实现的描述存在出入

// echo Calc::$op(2, 3), PHP_EOL;

用字符串拼出「类名::方法名」也可以当作可调用对象,'Calc::add' 这种形式自 PHP 7.0 起可用:

php 复制代码
<?php // 适用于 PHP 7.0+

$cb = 'Calc::add';                 // 类名 + 双冒号 + 方法名

var_dump(call_user_func($cb, 2, 3)); // int(5)

注意限制:这样调用的是静态方法 。如果目标方法是非静态的,用字符串静态调用会直接报错,不能借此把 $this 绑上去。

三、安全用法:白名单、callable 类型与首类可调用

可变函数本身没有安全属性,它的安全属性完全来自「函数名从哪来」 。函数名只要可能来自外部($_GET、$_POST、请求体 JSON、消息队列载荷),就必须经过白名单映射。

下面是一个推荐的分发模式:映射表是常量,落到表外的输入一律拒绝。

php 复制代码
<?php // 适用于 PHP 8.0+

declare(strict_types=1);



final class Formatter

{

    /** @var array<string, callable-string> */

    private const MAP = [

        'upper' => 'mb_strtoupper',

        'lower' => 'mb_strtolower',

        'trim'  => 'trim',

    ];



    public static function apply(string $name, string $value): string

    {

        // isset 查表等价于白名单校验,比 in_array 更快也更直白

        if (!isset(self::MAP[$name])) {

            throw new InvalidArgumentException("未知的格式化器: {$name}");

        }



        $fn = self::MAP[$name];

        return $fn($value);          // 变量函数调用:$fn 只可能来自 MAP

    }

}



echo Formatter::apply('upper', '  abc '), PHP_EOL;  // "  ABC "

echo Formatter::apply('trim', '  abc '), PHP_EOL;   // "abc"

如果确实要把可调用对象当参数传来传去,用 callable 类型声明让错误尽早暴露,而不是等到调用时才炸:

php 复制代码
<?php // 适用于 PHP 8.0+

declare(strict_types=1);



function pipe(mixed $value, callable ...$stages): mixed

{

    foreach ($stages as $stage) {

        $value = $stage($value);

    }

    return $value;

}



$result = pipe(

    '  Hello World  ',

    'trim',                                  // 字符串函数名

    'mb_strtolower',                         // 同上

    static fn (string $s): string => ucfirst($s)  // 箭头函数

);

echo $result, PHP_EOL;  // "Hello world"

这里 ...$stages 会自动把每个实参校验收成 callable,传进去一个不存在的函数名会立刻抛 TypeError,而不是在 foreach 里才失败。

Closure::fromCallable() 能把任意 callable 转成 Closure 对象,这样它就能像普通闭包一样被传递、绑定作用域:

php 复制代码
<?php // 适用于 PHP 7.1+

$fn = Closure::fromCallable('strlen');

var_dump($fn('abcd'));                     // int(4)



$obj = new ArrayObject(['a', 'b']);

$count = Closure::fromCallable([$obj, 'count']);

var_dump($count());                        // int(2)

PHP 8.1 起,同一件事有更短的语法------首类可调用(first-class callable syntax):

php 复制代码
<?php // 需要 PHP 8.1 及以上

$fn = strlen(...);                 // 等价于 Closure::fromCallable('strlen')

var_dump($fn('abcd'));             // int(4)



$map = array_map(strlen(...), ['a', 'bb', 'ccc']);

var_dump($map);                    // [1, 2, 3]

strlen(...) 里的三个点不是可变参数,而是「把这个可调用对象包成闭包」的记号。PHP 8.0 及以下没有这个语法,请退回 Closure::fromCallable('strlen')(7.1+)或直接写字符串 'strlen'。它也能用于方法:$obj->method(...)、self::method(...)。

常见坑点

❌ 函数名直接来自请求参数

php 复制代码
// ❌ 等于把「执行哪个函数」交给外部决定

$fn = $_GET['fn'] ?? '';

$fn($_GET['arg'] ?? '');

✅ 白名单映射,表外一律拒绝

php 复制代码
$allow = ['md5' => true, 'sha1' => true];

if (!isset($allow[$fn])) { throw new InvalidArgumentException('非法调用'); }

❌ 对语言构造使用可变函数写法

php 复制代码
// ❌ Fatal error: Call to undefined function echo()

$f = 'echo';

$f('hi');

✅ 用闭包包一层

php 复制代码
$f = static function (string $v): void { echo $v; };

$f('hi');

❌ 在命名空间里用不带前缀的字符串调函数

php 复制代码
// ❌ 字符串总是按全局名字解析,这里找的是 \hello 而不是 \App\hello

namespace App;

$fn = 'hello';

✅ 用 __NAMESPACE__ 拼出完全限定名

php 复制代码
$fn = __NAMESPACE__ . '\\hello';

❌ 以为 function_exists() 通过就万事大吉

php 复制代码
// ❌ function_exists 只查函数,不查类方法;也不能判断「该不该调」

if (function_exists($name)) { $name(); }

✅ 按对象类型选正确的检查函数,并且以白名单为准

php 复制代码
// 函数用 function_exists(),方法用 method_exists(),任意可调用用 is_callable()

if (isset($allow[$name]) && function_exists($name)) { $name(); }

❌ 用 is_callable() 当作权限判断

php 复制代码
// ❌ 类里只要定义了 __call,is_callable 对任意方法名都返回 true

if (is_callable([$obj, $_GET['m']])) { $obj->{$_GET['m']}(); }

✅ is_callable() 只做「是不是可调用」的确认

php 复制代码
$m = $_GET['m'] ?? '';

if (in_array($m, $obj->allowedMethods(), true) && is_callable([$obj, $m])) {

    $obj->{$m}();

}

❌ 在调用点写 & 想传引用

php 复制代码
// ❌ 调用时传引用(call-time pass-by-reference)自 PHP 5.4 起已被移除

sort_list(&$list);

✅ 引用只写在函数定义处;批量修改数组用 array_walk()

php 复制代码
function sort_list(array &$list): void { sort($list); }

array_walk($list, static function (string &$v): void { $v = trim($v); });

❌ 打算用 call_user_func() 改外部变量

php 复制代码
// ❌ 别把 call_user_func 当成引用通道,引用语义请用别的手段

call_user_func('bump', $counter);

✅ 让函数返回值,再由调用方赋值

php 复制代码
$counter = bump($counter);      // 明确的「传入 --- 返回 --- 赋值」链路

❌ 用 Foo::$method() 动态调静态方法并依赖其解析结果

php 复制代码
// ❌ 手册描述与实际实现有出入,各版本行为不一致,不要依赖

echo Calc::$op(2, 3);

✅ 用花括号把方法名表达式写清楚

php 复制代码
echo Calc::{$op}(2, 3);

总结

事项 结论

|------|----------------------------|
| 基本语义 | 先取变量值当函数名,再去符号表查;查不到就是致命错误 |

|------|--------------------------------------------|
| 语言构造 | echo、isset()、include 等不能当可变函数,需包一层闭包 |

|------|-----------------------------|
| 名字解析 | 字符串形式的函数名总是完全限定名,不受当前命名空间影响 |

|------|---------------------------------------|
| 实例方法 | $obj->$method(),方法不存在时触发 __call() |

|------|--------------------------------------------|
| 静态方法 | 用 Calc::{$op}(),不要依赖 Calc::$op() 的解析顺序 |

|--------|-----------------------------------------|
| 类方法字符串 | 'Cls::method' 自 PHP 7.0 起可用,且只能用于静态方法 |

|------|-----------------------------------------------------------|
| 现代写法 | Closure::fromCallable()(7.1+)、首类可调用 strlen(...)(8.1+) |

|------|-------------------------------------------|
| 安全底线 | 函数名可能来自外部时,一律白名单映射,is_callable() 不做权限判断 |

一句话总结:可变函数是一张「名字 → 实现」的查表机制,它的价值在于把控制流变成数据。也正因为是数据,它必须被当作不可信数据处理 ------把表建好、把校验放在查表这一步,比在调用点加判断可靠得多。函数名与参数顺序这类细节请以 PHP 官方手册为准;涉及 Foo::$method() 这种手册与实际存在出入的写法,稳妥做法是换成花括号形式,而不是去赌某个版本的具体行为。

相关推荐
Do_It_Today1 小时前
PHP 实现数组分页
开发语言·php
weishuangyun11 小时前
小程序制作平台怎么选?三大平台对比!
大数据·开发语言·javascript
我滴老baby1 小时前
Navidrome:飞牛OS安装、曲库导入与远程收听全流程
开发语言·数据库·php
code2cat1 小时前
Java进阶篇之AtomicLongArray:大数累加、上限检查与溢出边界
java·开发语言
小比特-combat1 小时前
LVGL_1(使用示例讲解父子关系)
开发语言·前端·javascript
开开心心_Every1 小时前
电脑OCR识别软件支持表格识别图片转Excel
java·开发语言·微信·计算机外设·ocr·intellij-idea·excel
徐小黑ACG1 小时前
Golang 基础03 函数
开发语言·后端·golang
Do_It_Today1 小时前
PHP xpath()函数讲解
开发语言·php
YYYing.1 小时前
【Python系列 (一) 】Python基础疑难杂症
开发语言·python