[PHP-DEV] Re: [Pre-RFC] Scalar object methods (with a working implementation)

Hi Seifeddine,

> Restricting dispatch to receivers "the compiler already knows are scalar"
> sounds safe, but in practice it covers almost no real code. [...] Move
> Example into its own autoloaded file [...] So $x->length() would not
> dispatch [...] usable only on literals and casts and almost nothing else.

The receiver in your example is $x, an untyped local, so $x->length() doesn't dispatch at all. Untyped variables are never receivers, and the feature never infers a variable's type from its assignment; it's syntactic, never optimizer-inferred. So file layout is actually irrelevant here: $x = Example::getStr(); $x->length(); wouldn't dispatch even with Example in the same file. The "call with a declared scalar return" rule is for direct chaining, f()->m(), not for a value you've assigned to a variable first. To make your example dispatch you type the variable:

     string $x = Example::getStr();
     $x->length();

and that is provable locally, wherever Example is defined.

(Method-call results are a separate case: Example::getStr()->length() and $obj->getName()->trim() aren't receivers either, static or instance, because proving their type would lean on inheritance and LSB the compiler can't see.)

Where you're right is the one form that genuinely has this problem: a direct f()->m() where f is a user function. That resolves only if f is already declared at the point of compilation, which is file-order dependent, and a whole-program analyser would accept what the single-file compiler rejects. Fair hit. I'll restrict that form to internal functions, where the compiler and the analyser agree unconditionally, or drop it. Marginal either way.

But "almost no real code" isn't right. The receivers that matter aren't cross-file: literals, casts, concatenation and interpolation, a $this typed property declared on the class being compiled, and typed locals. A typed local's type comes from its own declaration in the same function; the property's from the class being compiled. Single compilation unit, no file-layout dependence, and an analyser sees them identically. And when the compiler can't prove the type it doesn't dispatch, it falls through to today's behaviour. It never dispatches the wrong method; the failure mode is "current error, or you add a type", not a silent bug.

> Real-world values emerge from call chains, conditionals, and cross-file
> boundaries

Right, and that's exactly what the typed-local half is for:

     string $s = $user->getName();
     $s->trim();

is provable locally wherever getName() lives. The free-receiver half is the deliberately narrow, always-safe core; typed locals are how you get a receiver for a cross-file value.

> If $s->length() is sugar for Str::length($s), then Str [...] must be
> visible to userland in some form. [...] Solve the naming problem with
> naming, not by blinding the tooling.

The definitions aren't hidden. They're ordinary .stub.php files in core (str.stub.php, int.stub.php, float.stub.php), which is what PhpStorm, PHPStan and Psalm already consume for internal APIs. The NUL prefix only hides the runtime symbol, not the published signatures. And an analyser has to special-case scalar dispatch regardless, since a string isn't an object and $s->trim() is never an ordinary method lookup.

That said, "solve naming with naming" is fair. The NUL prefix was only to avoid adding a new global symbol to bikeshed. A reserved namespace or a non-colliding visible name is a real alternative, and if it's friendlier for tooling that's a good reason to prefer it. What shape would you actually want there?

> what happens with methods that take arguments, e.g. $s->indexOf($y)?
> Does an arity mismatch fail at compile time, or at runtime with
> ArgumentCountError

Same as any call to that method. It compiles to a static call on the backing method, so an arity or type mismatch is the normal ArgumentCountError / TypeError. No special path.

> trim($s), mb_trim($s), and $s->trim() would all coexist [...] it
> introduces a method-call syntax on values that carry no object identity.
> I don't think the language is better for it.

That last one is taste, so I won't try to talk you out of a no. Couple of points anyway. Additive isn't unusual: the pipe operator, named args and first-class callables all coexist with the older forms. And it makes no claim about object identity, it's compile-time sugar for a function call, $s->trim() is just Str::trim($s).

Either way that's a real fix, so thanks. The method sets here are deliberately minimal, just enough to show the mechanism working. They can grow later; I'm not trying to settle the set in this thread.

Michal

Hi Michal,

The receiver in your example is $x, an untyped local, so $x->length()
doesn’t dispatch at all.

Fine, then take the corrected version. Every narrowing you apply to keep dispatch “locally provable” makes the feature worse, because the property that matters to a user is the one being trimmed away: if <expr> is a string, <expr>->trim() should work. Not “works if you assigned it to a typed local first,” not “works unless it came from a bare function call,” not “works depending on what’s above or below it in the file.”

Seeing $s->trim() where $s is a string, I should know it works. A construct where identical AST dispatches or doesn’t depending on file position, a redundant cast, or whether a declaration sits above or below the call is not something people can reason about.

restrict that form to internal functions, where the compiler and the
analyser agree unconditionally

This is more fragile than the problem it fixes. A static analyser often can’t tell whether a function is “internal.” A function added as a builtin in 9.1 but provided by a polyfill on 9.0: a project supporting both has no single answer. The same call is internal on one target and userland on the other, so the analyser can’t soundly decide whether it dispatches. That’s a language construct whose validity depends on the runtime present.

Also, this does not take into consideration unresolved names at compile time, calling strlen($s)->multiply($y) inside namespace App with no imports. How would this be handled? Would it also not work?

The definitions aren’t hidden. They’re ordinary .stub.php files in core
(str.stub.php, …), which is what PhpStorm, PHPStan and Psalm already
consume.

This isn’t accurate, and I maintain Mago, so I can speak to it directly. We don’t consume PHP’s .stub.php files. Neither do PHPStan or Psalm; they ship their own stub sets, because core’s stubs aren’t expressive enough for analysis: no conditional return types, no generics, no ranges. Publishing str.stub.php doesn’t give analysers what they need.

PHPStan: https://github.com/phpstan/php-8-stubs/tree/main/stubs
Psalm: https://github.com/vimeo/psalm/tree/6.x/stubs
Mago: https://github.com/carthage-software/mago/tree/main/crates/prelude/assets/extensions

an analyser has to special-case scalar dispatch regardless, since a
string isn’t an object

It doesn’t. If $s->trim() works whenever $s is a string, the analysis is clean: I resolve string methods against whatever interface defines them, as if $s were an instance of it. What forces a hack is your design, a construct valid or invalid for identical AST depending on file order and surrounding declarations. I don’t know how I’d model that soundly. Your approach creates the special-casing you say is unavoidable.

That last one is taste

The “should scalars have methods” part is taste. But this already existed, unrestricted, and worked: Nikita’s scalar_objects (https://github.com/nikic/scalar_objects) dispatches on the runtime type, so $str->length() works for any string, regardless of where it came from.

so I won’t try to talk you out of a no.

You don’t have to :stuck_out_tongue: I don’t vote.

Cheers,
Seifeddine