前言
可变函数(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() 这种手册与实际存在出入的写法,稳妥做法是换成花括号形式,而不是去赌某个版本的具体行为。