rfc:default_expression

PHP RFC: Default expression

Introduction

The only way to pass the default value to a function or method parameter is to not pass anything. This can be particularly difficult in some circumstances, because the language does not offer an intuitive way to pass nothing. This RFC proposes to make the existing keyword, default, the canonical way to pass the default value. Moreover, default becomes a valid expression in the function argument context, meaning it can be creatively combined with any and all existing PHP expression grammars to augment the default value as it is passed, including assigning it to variables (see appendix II for a comprehensive list).

In its simplest form, default can be used as a single token.

f(default)

However, this is of limited usefulness. The main benefit comes from using default in expression contexts, the most common case being conditional expressions.

function g($p = null) {
    f($p ?? default);
}

In this example, function f will receive whatever its default value is for its first argument only when $p is null, otherwise it receives the value of $p.

Default as an expression

The principal benefit of default is in its employ within expressions. Consider the following example:

class Config {
    public function __construct(Theme $theme = new CuteTheme()) {}
}

By default, the configuration is constructed with a new CuteTheme. We want to allow users to specify a different theme, but if they do not specify anything, we should still use the default. For the purposes of this example, Config should be considered third-party code we cannot change. Given we want to conditionally apply the user's custom theme, traditionally we have a few options here, none of them good:

  1. Copy the current default from Config. This should be considered an invalid option, because we want to allow the upstream library to change the default without having to update our code.
  2. Filter and splat the theme argument.
  3. Reflection.
// Filter and splat.
function applyTheme(?string $theme = null) {
    return new Config(...array_filter([isset($theme) ? new $theme : null]));
}

Filter and splat is a viable but unintuitive way to conditionally pass nothing as an argument and only works if we're willing to sacrifice null as a placeholder for nothing. Moreover, it only works with a single argument when not specifying keys; as soon as two or more nullable arguments are involved, collapsing them in this manner risks passing the arguments in the wrong order. However, we could specify parameter names as keys to mitigate this.

// Reflect.
function applyTheme(?string $theme = null) {
    $param = new ReflectionParameter(['Config', '__construct'], 'theme');
    return new Config(isset($theme) ? new $theme : $param->getDefaultValue());
}

Reflection is a verbose option with performance overhead. It requires us to duplicate the class and method name we're calling and be explicit about the name or number of the argument, in order to obtain its default value.

default provides an elegant and intuitive solution to this problem.

// Conditional default.
function applyTheme(?string $theme = null) {
    return new Config(isset($theme) ? new $theme : default);
}

This is somewhat similar to reflection, but PHP knows which class, method and argument we're calling just from context.

Default as a single token

Default as a single token was proposed a decade ago as a mechanism for skipping some parameters, but was declined. Since then, named arguments has provided a way to implicitly pass nothing, by skipping over parameters we don't want to pass. Named arguments mostly preclude the usefulness of skipping parameters with default, but a curious consequence of named arguments is parameter names suddenly became part of our APIs. That is, changing parameter names now constitutes a compatibility break, whether libraries want to adopt this contract or not. Although it is neither the principal aim nor benefit of this RFC, it does provide an alternative syntax for passing defaults that does not rely on named arguments, and as such relinquishes the burden on libraries to affirm parameter names part of their API. That is, libraries could now elect to declare parameter names not part of their backwards-compatibility promise by offering passing default as an alternative.

// Skipping JSON depth parameter with named parameters.
json_decode($json, true, flags: JSON_THROW_ON_ERROR);
 
// Skipping JSON depth parameter with default.
json_decode($json, true, default, JSON_THROW_ON_ERROR);

Proposal

default is now a valid expression, but only in argument-passing contexts. In all other contexts it is invalid, save for those in which it was valid previously, namely switch statements and match expressions. That is, default may be used when calling global or namespaced functions, static or instance class methods (including anonymous classes), any callable (including closures and arrow functions), whether they are defined internally or in userland. Outside these contexts, where expressions would otherwise be accepted, attempting to use default will raise a compile-time error.

Attempting to pass default to a function with no parameters, or as an argument beyond the callee's defined parameter limit, will result in a runtime exception. Similarly, attempting to pass default to a required parameter, with no default defined, results in a runtime exception.

Variadics

Variadic arguments do not permit a default expression. Internally, the variadic argument does not even count towards a function's formal argument count, so attempting to pass default to it results in the same runtime error you receive when passing an argument beyond the callee's limit.

Named arguments

Named arguments and default expressions can be composed together, as in the following example.

$f = fn ($v = 1, $default = 2) => $v + $default;
$f(default: default + 1); // int(4)

Match expression

Since match expressions also makes use of the default token, the grammar for the default match arm had to be migrated from the language parser to the compiler to prevent conflicts. The existing semantics were perfectly preserved, so the only difference one might notice is a slightly different error message when attempting something invalid like trying to share conditions with the default match arm.

In the context of a function argument, match expressions may contain default, ergo the following is legal (albeit contrived).

F(match (default) {
    default => default
});

In the above example, the match expression and the match body (right of =>) are the default expression, while the default in the match condition (left of =>) is the special token denoting the default match arm to use when no other arm matches, the same as it was before this RFC. However, it is also possible to use the default expression as a condition, simply by combining it with any other expression syntax.

Although there is no identity expression that works for all types, if you know the type of default, casting it to the same type is one way to convert it to an expression.

$f = fn ($v = 1) => $v;
$f(match (1) {
    0 => 10,
    (int) default => 20,
    default => 30,
}); // int(20)

Note that whilst multiple default arms are still prohibited, since we converted one of them to an expression, it is not acting as the default and does not count against this restriction.

Internals

Internally, default is treated as a new opcode that causes the VM to perform a parameter default value lookup using reflection, albeit via an internal call that is more efficient than routing through the typical public interface. When default appears multiple times for the same argument, it is evaluated each time, causing each occurrence to point to a unique instance if the default is an object. If the lookup fails for any reason, a runtime exception will be thrown.

Currently the only known failure case is lookup of trampoline functions, which can be created by calling __invoke on a closure, as in (fn ($P = 1) => $P)->__invoke(default);. Considering this is not the intended, nor even a documented way of invoking a closure, it is supposed this limitation is very minor.

Discussion

The greatest concern is the proposed grammar is too permissive and has drawbacks. Secondary concerns include evaluating default in the calling context, and default values are now part of an object's public API. We will examine each of these issues in detail.

Limiting grammar

The most common request is to constrain the allowed expression list. As already noted, some expressions don't make much sense because they probably don't have any practical application, and some are not comfortable allowing expressions that don't make sense into the language. This implies coming up with an arbitrary exclusion list for certain expressions. Some proposed taking this a step further by disallowing default as expression input, which effectively rules out all operator classes except conditionals and invocations of match() that use default as a stand-alone output token.

// Expressions with default as output only.
F(1 ? default : 0)
F(1 ? 1 : default)
F(0 ?: default)
F(null ?? default)
F(match(1) { 1 => default })

Further, some even expressed concerns about allowing any expressions at all and would only be comfortable allowing default as an isolated token, as in skipparams.

Critics converged on a valid counter-point that permitting expressions changing the default's type breaks LSP, as demonstrated in the following example (code courtesy of Ilija). Some have suggested this might be solved by disallowing default to be passed to union types (including mixed).

class C {
    public function F(int $V = 1) {}
}
 
class D extends C {
    public function F(int|string $V = 's') {}
}
 
function test(C $C) {
    $C->F(default + 1);
}
 
test(new C); // OK.
test(new D); // Fatal error: Uncaught TypeError: Unsupported operand types: string + int.

Default as a dummy value

Currently default, as described by this RFC, is effectively replaced by the callee's default value and then passed to the callee from the caller, meaning the caller has full access to the default value. Some have argued for an implementation that more literally follows the premise of this RFC, which is that default is just a dumb token that is standing in for nothing; it does not represent any value to the caller and merely instructs the callee to use its default value in the same way as when not passing the argument.

Defaults as a contract

Some have argued allowing default to read argument default values, previously only accessible via reflection, suddenly makes defaults part of an object's published API. However, changing a default is a behavioural change for any caller previously relying on those defaults (by not passing any argument), ergo defaults have always been part of the published API.

Backward Incompatible Changes

None known.

Future Scope

It may be possible to overcome the limitation regarding trampoline functions. It is unclear whether there is a practical need to do so, but if the need should arise, this should be possible to implement without any BC break.

Voting

As per the voting RFC a yes/no vote with a 2/3 majority is needed for this proposal to be accepted.

Implement default expressions as described?
Real name Yes No
Final result: 0 0
This poll has been closed.

Regardless of how you vote above, we'd like to collect feedback on which limitations of this proposal would make it/still be acceptable for you, starting from the least significant to the most significant changes. For details on what each of these options mean, see discussion. You can vote for multiple options, but if you choose the last one then it doesn't make much sense to pick any others.

Which limited grammars would you support?
Real name No union types Only conditional expressions No expressions No default in arguments
Final result: 0 0 0 0
This poll has been closed.

Only the result of the primary vote has a clear path forward for inclusion into the language. None of the proposed alternatives come with any feasibility guarantees. However, if the primary vote fails, the secondary vote may inform whomsoever wishes to pursue a follow-up for limited application of default, which requires a new RFC and implementation for that counter-proposal.

Appendix I: Further examples

The main examples of default expressions so far have been conditional expressions using the null coalesce (??) and ternary (?:) operators. Let's look at one more non-conditional example using the binary pipe operator (|) to augment flags.

class Json {
    static function encode(mixed $value, int $flags = JSON_THROW_ON_ERROR): string
    {
        return json_encode($value, $flags);
    }
}

This static class wraps the internal function, json_encode to provide saner defaults for JSON encoding. In particular, it sets the JSON_THROW_ON_ERROR flag so our return type is guaranteed to be string, eliminating the possibility we have to deal with false as a return type. For the purposes of this contrived example, it is required to ignore the fact that the flag should be specified in the function body rather than the parameter default, to ensure the caller doesn't break this contract. Supposing we want to pretty-print our encoded JSON, we could call:

Json::encode([], JSON_PRETTY_PRINT);

However, this will override the throw on error flag, which we want to keep. Calling the function as:

Json::encode([], default | JSON_PRETTY_PRINT);

allows us to keep the default and append our pretty-print flag, which continues to work even if the Json class should elect to update its defaults later.

Appendix II: Default expressions

Throughout this RFC we have referred to default as an expression. This appendix lists all the ways default as an expression can be composed with other expressions. Not all of these examples will make much (or any) sense in practical terms, particularly those towards the end of the list, yet many may have uses depending on your requirements and creativity.

// Numeric binary operators
F(default + 1)
F(default - 1)
F(default * 2)
F(default / 2)
F(default % 2)
F(default & 1)
F(default | 1)
F(default ^ 2)
F(default << 1)
F(default >> 1)
F(default ** 2)
F(default <=> 2)
 
// Boolean binary operators
F(default === 2)
F(default !== 2)
F(default == '2')
F(default != '2')
F(default >= 1)
F(default <= 1)
F(default > 1)
F(default < 1)
F(default && 0)
F(default || 0)
F(default and 0)
F(default or 0)
F(default xor 0)
 
// Unary operators
F(+default)
F(-default)
F(!default)
F(~default)
 
// Conditional expressions
F(default ? 1 : 0)
F(1 ? default : 0)
F(1 ? 1 : default)
F(default ?: 0)
F(0 ?: default)
F(default ?? 0)
F(null ?? default)
 
// Variable assignments
F($V = default)
F($V += default)
F($V -= default)
F($V *= default)
F($V **= default)
F($V /= default)
F($V <<= default)
F($V >>= default)
F($V %= default)
F($V &= default)
F($V |= default)
F($V ^= default)
F($V .= default)
F($V ??= default)
F(list($V) = default)
F([, $V] = default)
 
// Casts
F((int)default)
F((double)default)
F((string)default)
F((array)default)
F((object)default)
F((bool)default)
 
// Match
F(match(default) { default => default })
 
// Callable
F((default)->M())
 
// Parens
F((((default))))
 
// Internal functions
F(empty(default))
F(include default)
F(include_once default)
F(require default)
F(require_once default)
 
// Misc
F(default instanceof C)
F(clone default)
F(throw default)
F(print default)

References

Special thanks

The implementation for this feature was heavily subsidised by invaluable input from Ilija and Bob Weinand. In particular, Ilija assessed the feasibility and guided the implementation path, and Bob submitted the entire Bison grammar patch! Without them, this feature would either have been impossible or highly scuffed. They, along with everyone else whom supported me from R11, have my utmost thanks! ❤

rfc/default_expression.txt · Last modified: 2024/08/29 21:46 by bilge