Skip to content

Commit 096c2ff

Browse files
committed
[POC] Support calling functions from constant expressions
I can think of two main approaches to adding function call support to PHP. This implements the latter. 1. Only allow functions that are actually deterministic and don't depend on ini settings to be used in constant declarations. For example, allow `\count()`, `\strlen()`, `\array_merge()`, and `\in_array()`, but possibly don't allow functions such as `strtolower()` (different in Turkish locale), sprintf() (Depends on `ini_get('precision')`, but so does `(string)EXPR`), `json_encode()` (The `json` extension can be disabled), or calls which aren't unambiguously resolved with `\` or `use function`. 2. Allow any function (user-defined or internal) to be called, leave it to coding practice guidelines to assert that constants are only used in safe ways. ------- - This POC can handle fully qualified, namespace relative, and not-FQ calls. - Argument unpacking is supported - It turns out parameter defaults evaluate some constants every time, depending on whether the PHP value is immutable. Possible solutions: 1. Permanently resolve parameter defaults for expressions containing function calls, which can be more expensive than evaluating a regular constant AST. 2. Permanently resolve expressions in parameter defaults 3. Don't start allowing calls in parameter defaults - This handles recursive definitions. It throws if a function call being evaluated ends up reaching the same call. - Static calls with known classes (other than static::) are probably easy to implement. - This also constrains function return values to be valid constants, (i.e. they can be the same values that define() would accept) and throws an error otherwise. In the future, `const X = [my_call()->x];` may be possible. - Variables aren't supported, because they depend on the declaration's scope, which may cease to exist. - TODO: Forbid backtick string syntax for shell_exec, which gets converted to a ZEND_AST_CALL node. ------- It turns out that function calls can already be invoked when evaluating a constant, e.g. from php's error handler. So it seems viable for PHP's engine to support regular calls, which this POC attempts to do. (Imagine an error handler defining SOME_DYNAMIC_CONST123 to be a dynamic value if a notice is emitted) for the expression `const X = [[]['undefined_index'], SOME_DYNAMIC_CONST123][1];`
1 parent 4cbffd8 commit 096c2ff

19 files changed

+510
-3
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
--TEST--
2+
Can call internal functions from class constants
3+
--FILE--
4+
<?php
5+
class Example {
6+
const X = sprintf("Hello, %s\n", "World");
7+
8+
public static function main() {
9+
echo "X is " . self::X . "\n";
10+
}
11+
}
12+
Example::main();
13+
?>
14+
--EXPECT--
15+
X is Hello, World
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
--TEST--
2+
Can call internal functions from class constants
3+
--FILE--
4+
<?php
5+
function normalize_keys(array $x) {
6+
// Should only invoke normalize_keys once
7+
echo "Normalizing the keys\n";
8+
$result = [];
9+
foreach ($x as $k => $value) {
10+
$result["prefix_$k"] = $value;
11+
}
12+
return $result;
13+
}
14+
class Example {
15+
const X = [
16+
'key1' => 'value1',
17+
'key2' => 'value2',
18+
];
19+
const Y = array_flip(self::X);
20+
const Z = normalize_keys(self::Y);
21+
}
22+
var_export(Example::Z);
23+
var_export(Example::Z);
24+
var_export(Example::Y);
25+
?>
26+
--EXPECT--
27+
Normalizing the keys
28+
array (
29+
'prefix_value1' => 'key1',
30+
'prefix_value2' => 'key2',
31+
)array (
32+
'prefix_value1' => 'key1',
33+
'prefix_value2' => 'key2',
34+
)array (
35+
'value1' => 'key1',
36+
'value2' => 'key2',
37+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
--TEST--
2+
Can call internal functions from global constants
3+
--FILE--
4+
<?php
5+
const NIL = var_export(null, true);
6+
7+
echo "NIL is " . NIL . "\n";
8+
?>
9+
--EXPECT--
10+
NIL is NULL
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
--TEST--
2+
Cannot declare constants with function calls that contain objects
3+
--FILE--
4+
<?php
5+
function make_object_array() {
6+
return [new stdClass()];
7+
}
8+
const OBJECT_VALUES = make_object_array();
9+
?>
10+
--EXPECTF--
11+
Fatal error: Uncaught Error: Calls in constants may only evaluate to scalar values, arrays or resources in %s:5
12+
Stack trace:
13+
#0 {main}
14+
thrown in %s on line 5
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
--TEST--
2+
Can call internal functions from parameter default
3+
--FILE--
4+
<?php
5+
function evaluated_user_function() {
6+
// NOTE: PHP only caches non-refcounted values in the RECV_INIT value,
7+
// meaning that if the returned value is dynamic, this will get called every time.
8+
// TODO: Would it be worth it to convert refcounted values to immutable values?
9+
echo "Evaluating default\n";
10+
return sprintf("%s default", "Dynamic");
11+
}
12+
function test_default($x = evaluated_user_function()) {
13+
echo "x is $x\n";
14+
}
15+
test_default();
16+
test_default(2);
17+
test_default();
18+
?>
19+
--EXPECT--
20+
Evaluating default
21+
x is Dynamic default
22+
x is 2
23+
Evaluating default
24+
x is Dynamic default
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
--TEST--
2+
Warn when calling function expecting a reference as an argument
3+
--INI--
4+
error_reporting=E_ALL
5+
--FILE--
6+
<?php
7+
class Example {
8+
const VALUES = [];
9+
const IS_MATCH = preg_match('/test/', 'testing');
10+
const IS_MATCH_V2 = preg_match('/test/', 'testing', self::VALUES);
11+
12+
public static function main() {
13+
echo "X is " . self::X . "\n";
14+
}
15+
}
16+
var_dump(Example::IS_MATCH);
17+
var_dump(Example::IS_MATCH_V2);
18+
var_dump(Example::VALUES);
19+
--EXPECTF--
20+
int(1)
21+
22+
Warning: Parameter 3 to preg_match() expected to be a reference, value given in %s on line 12
23+
int(1)
24+
array(0) {
25+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
--TEST--
2+
Can call user-defined functions from defaults of static properties
3+
--FILE--
4+
<?php
5+
namespace NS;
6+
7+
function log_call($arg) {
8+
echo "log_call(" . var_export($arg, true) . ")\n";
9+
return $arg;
10+
}
11+
12+
class MyClass {
13+
public static $DEBUG = log_call(true);
14+
public static $DEBUG2 = namespace\log_call(range(1,2));
15+
}
16+
echo "Start\n";
17+
var_export(MyClass::$DEBUG); echo "\n";
18+
var_export(MyClass::$DEBUG2); echo "\n";
19+
var_export(MyClass::$DEBUG); echo "\n";
20+
MyClass::$DEBUG = "New value";
21+
echo MyClass::$DEBUG . "\n";
22+
?>
23+
--EXPECT--
24+
Start
25+
log_call(true)
26+
log_call(array (
27+
0 => 1,
28+
1 => 2,
29+
))
30+
true
31+
array (
32+
0 => 1,
33+
1 => 2,
34+
)
35+
true
36+
New value
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
--TEST--
2+
Recursion in calls in class constants causes an error
3+
--FILE--
4+
<?php
5+
function x_plus_1() {
6+
echo "Computing X + 1\n";
7+
return Recursion::X + 1;
8+
}
9+
class Recursion {
10+
const X = x_plus_1();
11+
const MISSING = MISSING_GLOBAL + 1;
12+
}
13+
try {
14+
echo "Recursion::X=" . Recursion::X . "\n";
15+
} catch (Error $e) {
16+
printf("Caught %s: %s\n", get_class($e), $e->getMessage());
17+
}
18+
try {
19+
echo "Recursion::X=" . Recursion::X . "\n";
20+
} catch (Error $e) {
21+
printf("Second call caught %s: %s\n", get_class($e), $e->getMessage());
22+
}
23+
try {
24+
echo "Recursion::MISSING=" . Recursion::MISSING;
25+
} catch (Error $e) {
26+
printf("Caught %s: %s\n", get_class($e), $e->getMessage());
27+
}
28+
try {
29+
echo "Recursion::MISSING=" . Recursion::MISSING;
30+
} catch (Error $e) {
31+
printf("Second call caught %s: %s\n", get_class($e), $e->getMessage());
32+
}
33+
?>
34+
--EXPECT--
35+
Computing X + 1
36+
Caught Error: Unrecoverable error calling x_plus_1() in recursive constant definition
37+
Computing X + 1
38+
Second call caught Error: Unrecoverable error calling x_plus_1() in recursive constant definition
39+
Caught Error: Undefined constant 'MISSING_GLOBAL'
40+
Second call caught Error: Undefined constant 'MISSING_GLOBAL'
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
--TEST--
2+
Can call internal functions from defaults of static variables
3+
--FILE--
4+
<?php
5+
function main() {
6+
static $call = SPRINTF("%s!", sprintf("Hello, %s", "World"));
7+
echo "$call\n";
8+
}
9+
main();
10+
main();
11+
?>
12+
--EXPECT--
13+
Hello, World!
14+
Hello, World!
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
--TEST--
2+
Can call user-defined functions from defaults of static variables
3+
--FILE--
4+
<?php
5+
function log_call(string $arg) {
6+
echo "log_call('$arg')\n";
7+
return $arg;
8+
}
9+
10+
$f = function () {
11+
static $call = log_call(sprintf("Hello, %s", "World"));
12+
echo "$call\n";
13+
};
14+
$f();
15+
$f();
16+
?>
17+
--EXPECT--
18+
log_call('Hello, World')
19+
Hello, World
20+
Hello, World
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
--TEST--
2+
Can call internal functions from defaults of static variables
3+
--FILE--
4+
<?php
5+
function main() {
6+
static $call = missing_sprintf("%s!", sprintf("Hello, %s", "World"));
7+
echo "$call\n";
8+
}
9+
for ($i = 0; $i < 2; $i++) {
10+
try {
11+
main();
12+
} catch (Error $e) {
13+
printf("Caught %s: %s at line %d\n", get_class($e), $e->getMessage(), $e->getLine());
14+
}
15+
}
16+
?>
17+
--EXPECT--
18+
Caught Error: Call to undefined function missing_sprintf() at line 3
19+
Caught Error: Call to undefined function missing_sprintf() at line 3

Zend/tests/call_in_const/too_few.phpt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
--TEST--
2+
ArgumentCountError thrown if constant contains call with too few arguments
3+
--FILE--
4+
<?php
5+
class Example {
6+
const X = sprintf();
7+
8+
public static function main() {
9+
echo "X is " . self::X . "\n";
10+
}
11+
}
12+
for ($i = 0; $i < 2; $i++) {
13+
try {
14+
Example::main();
15+
} catch (ArgumentCountError $e) {
16+
printf("Caught %s on line %d\n", $e->getMessage(), $e->getLine());
17+
}
18+
}
19+
?>
20+
--EXPECT--
21+
Caught sprintf() expects at least 1 parameter, 0 given on line 6
22+
Caught sprintf() expects at least 1 parameter, 0 given on line 6

Zend/tests/call_in_const/varargs.phpt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
--TEST--
2+
Allow varargs in calls in constants
3+
--INI--
4+
error_reporting=E_ALL
5+
--FILE--
6+
<?php
7+
const ARGS = ['Hello, %s', 'World'];
8+
const RESULT = sprintf(...ARGS);
9+
const RESULT_LINE = sprintf("%s\n", ...[RESULT]);
10+
echo RESULT_LINE;
11+
echo RESULT_LINE;
12+
--EXPECTF--
13+
Hello, World
14+
Hello, World

Zend/zend.c

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,9 @@ int zend_startup(zend_utility_functions *utility_functions) /* {{{ */
927927
#endif
928928
EG(error_reporting) = E_ALL & ~E_NOTICE;
929929

930+
CG(active_calls_in_constants) = (HashTable *)malloc(sizeof(HashTable));
931+
zend_hash_init_ex(CG(active_calls_in_constants), 128, NULL, NULL, 1, 0);
932+
930933
zend_interned_strings_init();
931934
zend_startup_builtin_functions();
932935
zend_register_standard_constants();
@@ -962,7 +965,7 @@ void zend_register_standard_ini_entries(void) /* {{{ */
962965
}
963966
/* }}} */
964967

965-
static zend_class_entry *resolve_type_name(zend_string *type_name) {
968+
static zend_class_entry *resolve_type_name(zend_string *type_name) {
966969
zend_string *lc_type_name = zend_string_tolower(type_name);
967970
zend_class_entry *ce = zend_hash_find_ptr(CG(class_table), lc_type_name);
968971

@@ -1083,6 +1086,8 @@ void zend_shutdown(void) /* {{{ */
10831086

10841087
zend_hash_destroy(GLOBAL_CONSTANTS_TABLE);
10851088
free(GLOBAL_CONSTANTS_TABLE);
1089+
zend_hash_destroy(CG(active_calls_in_constants));
1090+
free(CG(active_calls_in_constants));
10861091
zend_shutdown_strtod();
10871092

10881093
#ifdef ZTS

0 commit comments

Comments
 (0)