Skip to content

Commit

Permalink
[POC] Support calling functions from most constant expressions
Browse files Browse the repository at this point in the history
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
- Parameter defaults are evaluated every time if the expression
  contains function calls.
- This handles recursive definitions.
  It throws if a function call being evaluated ends up reaching the same call.
- Static method calls with known classes (other than static::)
  are probably easy to implement, but omitted from this RFC.
- 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,
  but returning an object in any call is forbidden here.
- Variables and functions such as `func_get_args()` aren't supported,
  because they depend on the declaration's scope.
- 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 does

(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];`)

Function calls are allowed in the following types of constant expressions

- Defaults of static properties, but not instance properties,
  due to changes required to the PHP internals expanding the scope
  of this RFC too much.
- Parameter defaults
- Global constants and class constants
- Defaults of static variables
  • Loading branch information
TysonAndre committed Feb 9, 2020
1 parent 3796597 commit bd34c05
Show file tree
Hide file tree
Showing 26 changed files with 638 additions and 4 deletions.
15 changes: 15 additions & 0 deletions Zend/tests/call_in_const/class_const01.phpt
@@ -0,0 +1,15 @@
--TEST--
Can call internal functions from class constants
--FILE--
<?php
class Example {
const X = sprintf("Hello, %s\n", "World");

public static function main() {
echo "X is " . self::X . "\n";
}
}
Example::main();
?>
--EXPECT--
X is Hello, World
37 changes: 37 additions & 0 deletions Zend/tests/call_in_const/class_const02.phpt
@@ -0,0 +1,37 @@
--TEST--
Can call internal functions from class constants
--FILE--
<?php
function normalize_keys(array $x) {
// Should only invoke normalize_keys once
echo "Normalizing the keys\n";
$result = [];
foreach ($x as $k => $value) {
$result["prefix_$k"] = $value;
}
return $result;
}
class Example {
const X = [
'key1' => 'value1',
'key2' => 'value2',
];
const Y = array_flip(self::X);
const Z = normalize_keys(self::Y);
}
var_export(Example::Z);
var_export(Example::Z);
var_export(Example::Y);
?>
--EXPECT--
Normalizing the keys
array (
'prefix_value1' => 'key1',
'prefix_value2' => 'key2',
)array (
'prefix_value1' => 'key1',
'prefix_value2' => 'key2',
)array (
'value1' => 'key1',
'value2' => 'key2',
)
25 changes: 25 additions & 0 deletions Zend/tests/call_in_const/class_const03.phpt
@@ -0,0 +1,25 @@
--TEST--
Cannot call functions accessing the variable scope in class constants
--FILE--
<?php
class Example {
const X = func_get_args();

public static function main($value) {
try {
var_export(self::X);
} catch (Error $e) {
printf("Caught %s on line %d\n", $e->getMessage(), $e->getLine());
}
try {
var_export(self::X);
} catch (Error $e) {
printf("Caught %s on line %d\n", $e->getMessage(), $e->getLine());
}
}
}
Example::main('test');
?>
--EXPECT--
Caught Cannot call func_get_args() dynamically on line 7
Caught Cannot call func_get_args() dynamically on line 12
10 changes: 10 additions & 0 deletions Zend/tests/call_in_const/global_const01.phpt
@@ -0,0 +1,10 @@
--TEST--
Can call internal functions from global constants
--FILE--
<?php
const NIL = var_export(null, true);

echo "NIL is " . NIL . "\n";
?>
--EXPECT--
NIL is NULL
14 changes: 14 additions & 0 deletions Zend/tests/call_in_const/global_const02.phpt
@@ -0,0 +1,14 @@
--TEST--
Cannot declare constants with function calls that contain objects
--FILE--
<?php
function make_object_array() {
return [new stdClass()];
}
const OBJECT_VALUES = make_object_array();
?>
--EXPECTF--
Fatal error: Uncaught Error: Calls in constants may only evaluate to scalar values, arrays or resources in %s:5
Stack trace:
#0 {main}
thrown in %s on line 5
24 changes: 24 additions & 0 deletions Zend/tests/call_in_const/param_defaults01.phpt
@@ -0,0 +1,24 @@
--TEST--
Can call internal functions from parameter default
--FILE--
<?php
function evaluated_user_function() {
// NOTE: PHP only caches non-refcounted values in the RECV_INIT value,
// meaning that if the returned value is dynamic, this will get called every time.
// TODO: Would it be worth it to convert refcounted values to immutable values?
echo "Evaluating default\n";
return sprintf("%s default", "Dynamic");
}
function test_default($x = evaluated_user_function()) {
echo "x is $x\n";
}
test_default();
test_default(2);
test_default();
?>
--EXPECT--
Evaluating default
x is Dynamic default
x is 2
Evaluating default
x is Dynamic default
28 changes: 28 additions & 0 deletions Zend/tests/call_in_const/param_defaults02.phpt
@@ -0,0 +1,28 @@
--TEST--
Cannot access variable scope in parameter defaults
--FILE--
<?php

namespace NS;

use Error;

function test_default($x = func_get_args()) {
var_dump($x);
}
try {
test_default();
} catch (Error $e) {
printf("Caught %s on line %d\n", $e->getMessage(), $e->getLine());
}
test_default('overriding the default');
try {
test_default();
} catch (Error $e) {
printf("Caught %s on line %d\n", $e->getMessage(), $e->getLine());
}
?>
--EXPECT--
Caught Cannot call func_get_args() dynamically on line 7
string(22) "overriding the default"
Caught Cannot call func_get_args() dynamically on line 7
22 changes: 22 additions & 0 deletions Zend/tests/call_in_const/param_defaults03.phpt
@@ -0,0 +1,22 @@
--TEST--
User-defined functions are evaluated every time in param defaults
--FILE--
<?php

function generate_new_id() : int {
static $id = 100;
++$id;
return $id;
}

function test_id(int $id = generate_new_id()) {
echo "id is $id\n";
}
test_id();
test_id(-1);
test_id();
?>
--EXPECT--
id is 101
id is -1
id is 102
25 changes: 25 additions & 0 deletions Zend/tests/call_in_const/param_ref.phpt
@@ -0,0 +1,25 @@
--TEST--
Warn when calling function expecting a reference as an argument
--INI--
error_reporting=E_ALL
--FILE--
<?php
class Example {
const VALUES = [];
const IS_MATCH = preg_match('/test/', 'testing');
const IS_MATCH_V2 = preg_match('/test/', 'testing', self::VALUES);

public static function main() {
echo "X is " . self::X . "\n";
}
}
var_dump(Example::IS_MATCH);
var_dump(Example::IS_MATCH_V2);
var_dump(Example::VALUES);
--EXPECTF--
int(1)

Warning: Parameter 3 to preg_match() expected to be a reference, value given in %s on line 12
int(1)
array(0) {
}
36 changes: 36 additions & 0 deletions Zend/tests/call_in_const/property_default01.phpt
@@ -0,0 +1,36 @@
--TEST--
Can call user-defined functions from defaults of static properties
--FILE--
<?php
namespace NS;

function log_call($arg) {
echo "log_call(" . var_export($arg, true) . ")\n";
return $arg;
}

class MyClass {
public static $DEBUG = log_call(true);
public static $DEBUG2 = namespace\log_call(range(1,2));
}
echo "Start\n";
var_export(MyClass::$DEBUG); echo "\n";
var_export(MyClass::$DEBUG2); echo "\n";
var_export(MyClass::$DEBUG); echo "\n";
MyClass::$DEBUG = "New value";
echo MyClass::$DEBUG . "\n";
?>
--EXPECT--
Start
log_call(true)
log_call(array (
0 => 1,
1 => 2,
))
true
array (
0 => 1,
1 => 2,
)
true
New value
16 changes: 16 additions & 0 deletions Zend/tests/call_in_const/property_default02.phpt
@@ -0,0 +1,16 @@
--TEST--
Cannot call functions from defaults of instance properties
--FILE--
<?php
// Currently, php will evaluate all of the instance property defaults at once and cache them
// the first time a class gets instantiated, in _object_and_properties_init.
//
// Authors of future RFCs may wish to change PHP
// to allow `count()` or `generate_unique_id()` or `public $fields = new stdClass();`
// as the defaults of instance properties.
class MyClass {
public $v1 = count([]);
}
?>
--EXPECTF--
Fatal error: Default value for instance property MyClass::$v1 cannot contain function calls in %s on line 9
40 changes: 40 additions & 0 deletions Zend/tests/call_in_const/recursion.phpt
@@ -0,0 +1,40 @@
--TEST--
Recursion in calls in class constants causes an error
--FILE--
<?php
function x_plus_1() {
echo "Computing X + 1\n";
return Recursion::X + 1;
}
class Recursion {
const X = x_plus_1();
const MISSING = MISSING_GLOBAL + 1;
}
try {
echo "Recursion::X=" . Recursion::X . "\n";
} catch (Error $e) {
printf("Caught %s: %s\n", get_class($e), $e->getMessage());
}
try {
echo "Recursion::X=" . Recursion::X . "\n";
} catch (Error $e) {
printf("Second call caught %s: %s\n", get_class($e), $e->getMessage());
}
try {
echo "Recursion::MISSING=" . Recursion::MISSING;
} catch (Error $e) {
printf("Caught %s: %s\n", get_class($e), $e->getMessage());
}
try {
echo "Recursion::MISSING=" . Recursion::MISSING;
} catch (Error $e) {
printf("Second call caught %s: %s\n", get_class($e), $e->getMessage());
}
?>
--EXPECT--
Computing X + 1
Caught Error: Unrecoverable error calling x_plus_1() in recursive constant definition
Computing X + 1
Second call caught Error: Unrecoverable error calling x_plus_1() in recursive constant definition
Caught Error: Undefined constant 'MISSING_GLOBAL'
Second call caught Error: Undefined constant 'MISSING_GLOBAL'
14 changes: 14 additions & 0 deletions Zend/tests/call_in_const/static_default01.phpt
@@ -0,0 +1,14 @@
--TEST--
Can call internal functions from defaults of static variables
--FILE--
<?php
function main() {
static $call = SPRINTF("%s!", sprintf("Hello, %s", "World"));
echo "$call\n";
}
main();
main();
?>
--EXPECT--
Hello, World!
Hello, World!
20 changes: 20 additions & 0 deletions Zend/tests/call_in_const/static_default02.phpt
@@ -0,0 +1,20 @@
--TEST--
Can call user-defined functions from defaults of static variables
--FILE--
<?php
function log_call(string $arg) {
echo "log_call('$arg')\n";
return $arg;
}

$f = function () {
static $call = log_call(sprintf("Hello, %s", "World"));
echo "$call\n";
};
$f();
$f();
?>
--EXPECT--
log_call('Hello, World')
Hello, World
Hello, World
19 changes: 19 additions & 0 deletions Zend/tests/call_in_const/static_default03.phpt
@@ -0,0 +1,19 @@
--TEST--
Can call internal functions from defaults of static variables
--FILE--
<?php
function main() {
static $call = missing_sprintf("%s!", sprintf("Hello, %s", "World"));
echo "$call\n";
}
for ($i = 0; $i < 2; $i++) {
try {
main();
} catch (Error $e) {
printf("Caught %s: %s at line %d\n", get_class($e), $e->getMessage(), $e->getLine());
}
}
?>
--EXPECT--
Caught Error: Call to undefined function missing_sprintf() at line 3
Caught Error: Call to undefined function missing_sprintf() at line 3
22 changes: 22 additions & 0 deletions Zend/tests/call_in_const/too_few.phpt
@@ -0,0 +1,22 @@
--TEST--
ArgumentCountError thrown if constant contains call with too few arguments
--FILE--
<?php
class Example {
const X = sprintf();

public static function main() {
echo "X is " . self::X . "\n";
}
}
for ($i = 0; $i < 2; $i++) {
try {
Example::main();
} catch (ArgumentCountError $e) {
printf("Caught %s on line %d\n", $e->getMessage(), $e->getLine());
}
}
?>
--EXPECT--
Caught sprintf() expects at least 1 parameter, 0 given on line 6
Caught sprintf() expects at least 1 parameter, 0 given on line 6
14 changes: 14 additions & 0 deletions Zend/tests/call_in_const/varargs.phpt
@@ -0,0 +1,14 @@
--TEST--
Allow varargs in calls in constants
--INI--
error_reporting=E_ALL
--FILE--
<?php
const ARGS = ['Hello, %s', 'World'];
const RESULT = sprintf(...ARGS);
const RESULT_LINE = sprintf("%s\n", ...[RESULT]);
echo RESULT_LINE;
echo RESULT_LINE;
--EXPECTF--
Hello, World
Hello, World

0 comments on commit bd34c05

Please sign in to comment.