rfc:operator_overrides_lite

PHP RFC: Operator Overrides -- Lite Edition

Introduction

Nearly three years ago, PHP RFC: User Defined Operator Overloads was declined due to scope and new syntax concerns. However, the GMP class, which represents integer numbers in the GMP extension, was (accidentally) left non-final. This RFC explores the potential of extending GMP with a limited form of operator overriding, providing cleaner expressions for mathematical constructs and allowing new types of numerical representations such as units, complex numbers, and more.

This is to integers as ArrayAccess is to arrays, and Stringable is to strings, providing a simplified framework for defining integer mathematics, such as units.

What this is not

This is not a full operator overrides implementation; it is much simpler and it does not stop us from implementing that in the future. It, does, however seek to be a polar opposite of the PHP RFC: User Defined Operator Overloads RFC.

Why the GMP extension?

Several curious people have wondered why this is focused on the GMP extension. The reasons are quite simple:

  1. Numbers are surprisingly hard to implement “from scratch” (which is required if implemented in another extension) and libraries like GMP/BCMath are great libraries that make this simple.
  2. GMP has a practically infinite numerical range, meaning if you wanted to write a “Duration” library that counted in nanoseconds, you could have a range larger than 292 years (the limit for 64 bit machines using PHP_INT_MAX).
  3. People using the GMP extension are already familiar with the way overloading works in the engine.
  4. The extension already has all the plumbing in-place to handle casting, operator overloads, and all in an OOP oriented way.

Proposal

The \GMP class will be changed to the following signature:

readonly class GMP
{
    public function __construct(int|string|GMP $num = 0, int $base = 0) {}
 
    public function __serialize(): array {}
 
    public function __unserialize(array $data): void {}
 
    protected function add(GMP|int|string $left, GMP|int|string $right): GMP {}
 
    protected function multiply(GMP|int|string $left, GMP|int|string $right): GMP {}
 
    protected function subtract(GMP|int|string $left, GMP|int|string $right): GMP {}
 
    protected function divide(GMP|int|string $left, GMP|int|string $right): GMP {}
 
    protected function mod(GMP|int|string $left, GMP|int|string $right): GMP {}
 
    protected function pow(GMP|int|string $base, GMP|int|string $exp): GMP {}
 
    protected function comparable(GMP|int|string $op1, GMP|int|string $op2): bool {}
}

The changes are listed below:

  1. The class is made readonly to prevent developers from using state in inherited types.
  2. The constructor accepts another GMP instance instead of only string|int
  3. Mathematical methods are added to the class as protected. It is expected that developers wanting to support an operation make the associated operation public.

For any existing GMP code, absolutely nothing changes. The extension's behavior ONLY changes when the developer extends the GMP class and uses it mathematically. To be usable, the developer must override the desired operations and make them public. This ensures the class can be used even if the GMP extension is unavailable. A polyfill is included in the example below, which can be included in libraries that use this functionality.

Note the lack of equals, less-than, and greater-than operators. These are deliberately left out and replaced by a “comparable” method where the developer can indicate whether the two objects are compatible. They are left out for several reasons:

  1. PHP reorders comparables as needed
  2. The core tenant here is that these objects should represent types of integers, and it is expected that they are internally comparable but possibly not comparable to other types. For example, the number of goats on a farm may not be comparable to the speed of a car.

Shift and bitwise operations are also left out of the class because it is expected that these behaviors won't change for any type of number that could be implemented here.

Below is a listing showing a polyfill, a Duration class that only allows adding, multiplying, and exponentiation with scalars and is only comparable to Durations. Further, it cannot be negative.

<?php
 
if(!class_exists('GMP')) {
    // polyfill for completion
    readonly class GMP {
        public function __construct(private int|string|GMP $num) {}
 
        protected function add(GMP|int|string $left, GMP|int|string $right): GMP {
            return new self($left?->num + $right?->num);
        }
 
        protected function multiply(GMP|int|string $left, GMP|int|string $right): GMP {
            return new self($left?->num * $right?->num);
        }
 
        protected function subtract(GMP|int|string $left, GMP|int|string $right): GMP {
            return new self($left?->num - $right?->num);
        }
 
        protected function divide(GMP|int|string $left, GMP|int|string $right): GMP {
            return new self($left?->num / $right?->num);
        }
 
        protected function mod(GMP|int|string $left, GMP|int|string $right): GMP {
            return new self($left?->num % $right?->num);
        }
 
        protected function pow(GMP|int|string $base, GMP|int|string $exp): GMP {
            return new self($base?->num ** $exp?->num);
        }
 
        protected function comparable(GMP|int|string $op1, GMP|int|string $op2): bool {
            return is_numeric($op1) && is_numeric($op2);
        }
    }
}
 
readonly class Duration extends GMP {
    public function __construct(int|string|GMP $num = 0)
    {
        if($num < 0) {
            throw new ArithmeticError('Duration cannot be negative.');
        }
 
        parent::__construct($num);
    }
 
    private function assertValidScalar(mixed $maybeScalar, string $operation): void {
        if(is_numeric($maybeScalar)) {
            return;
        }
 
        if(get_class($maybeScalar) === 'GMP') {
            return;
        }
 
        throw new ValueError("Can only perform $operation on GMP or int. Got " . get_debug_type($maybeScalar) . " instead.");
    }
 
    private function guardScalars(GMP|int|string $left, GMP|int|string $right, string $operation): void
    {
        if($left === $this) {
            $this->assertValidScalar($right, $operation);
            return;
        }
 
        if($right === $this) {
            $this->assertValidScalar($left, $operation);
            return;
        }
 
        throw new LogicException('$left or $right must be the same instance as $this.');
    }
 
    private function getResult(GMP $result): self {
        return new self($result);
    }
 
    public function add(GMP|int|string $left, GMP|int|string $right): static
    {
        $this->guardScalars($left, $right, 'addition');
 
        return $this->getResult(parent::add($left, $right));
    }
 
    public function multiply(GMP|int|string $left, GMP|int|string $right): GMP
    {
        $this->guardScalars($left, $right, 'multiplication');
 
        return $this->getResult(parent::multiply($left, $right));
    }
 
    public function pow(GMP|int|string $base, GMP|int|string $exp): GMP
    {
        $this->assertValidScalar($exp, 'exponentiation');
 
        if($base !== $this) {
            throw new LogicException('$base must be the same instance as $this.');
        }
 
        return $this->getResult(parent::pow($base, $exp));
    }
 
    public function comparable(GMP|int|string $op1, GMP|int|string $op2): bool
    {
        return $op1 instanceof self && $op2 instanceof self;
    }
}
 
$duration = new Duration(10);
$other = new Duration(200);
$regular = new GMP(10);
 
function do_op($description, $op): void {
    global $duration, $other, $regular;
    try {
        $result = eval('return ' . $op . ';');
        echo "$description: $op = $result" . PHP_EOL;
    } catch (Throwable $exception) {
        echo "$description: $op = [" . get_class($exception) . ': ' . $exception->getMessage() . ']' . PHP_EOL;
    }
}
 
do_op('Duration', '$duration');
do_op('Regular', '$regular');
do_op('Other', '$other');
 
do_op('Regular', '$regular + 10');
do_op('Duration', '$duration + 10');
do_op('Duration + Duration', '$duration + $other');
do_op('Duration + Regular', '$duration + $regular');
do_op('Division not allowed', '$duration / 10');
do_op('Multiplication', '$regular * 10');
do_op('No negatives', '$duration + -20');
do_op('Comparison', '$duration < $other');
do_op('Comparison failure', '$duration < 20');
 
/**
 * Output:
 * Duration: $duration = 10
 * Regular: $regular = 10
 * Other: $other = 200
 * Regular: $regular + 10 = 20
 * Duration: $duration + 10 = 20
 * Duration + Duration: $duration + $other = [ValueError: Can only perform addition on GMP or int. Got Duration instead.]
 * Duration + Regular: $duration + $regular = 20
 * Division not allowed: $duration / 10 = [Error: Invalid callback Duration::divide, cannot access protected method Duration::divide()]
 * Multiplication: $regular * 10 = 100
 * No negatives: $duration + -20 = [ArithmeticError: Duration cannot be negative.]
 * Comparison: $duration < $other = 1
 * Comparison failure: $duration < 20 = [ArithmeticError: Can't compare incompatible types]
 */

The following exceptions can be thrown by the engine while attempting to perform mathematical operations:

  • Error: when trying to perform an unsupported operation, which shows as “Invalid callback Duration::divide, cannot access protected method Duration::divide()”
  • ArithmeticError: when ANY participating comparison indicates that it is not comparable: “Can't compare incompatible types: Duration to int”

Additionally, the implementor may throw additional exceptions, such as a ValueError in the example above, to indicate that something is not possible to the developer.

Backward Incompatible Changes

There are no backward incompatible changes. Existing GMP-based code will remain unaffected.

Proposed PHP Version(s)

8.4 if time allows, or the next version.

RFC Impact

To SAPIs

No impact.

To Existing Extensions

Only GMP will be affected.

To Opcache

There should be no impact to Opcache.

Open Issues

None, yet.

Unaffected PHP Functionality

Code using the GMP extension without extending it will remain unchanged.

Future Scope

  • support for other operators
  • serialization/unserialization
  • support for non-integers via BCMath

Proposed Voting Choices

  • 2/3 YES|NO vote: Allow extending the \GMP class and use a form of operator overloading
  • 2/3 YES|NO secondary vote: Disallow extending the \GMP class if this RFC fails

Patches and Tests

Prototype patch: PR 14730

Implementation

After the project is implemented, this section should contain

  1. a link to the git commit(s)
  2. a link to the PHP manual entry for the feature
  3. a link to the language specification section (if any)

References

Links to external references, discussions or RFCs

Rejected Features and Concerns

A separate extension

On paper, a separate extension sounds like a good idea. However, I've attempted this with varying degrees of success. Reimplementing mathematics is not straightforward or a good idea when libraries like GMP do a much better job. Since there is already a GMP extension, it makes sense to merge it with that extension instead of forking it. If this RFC is rejected, it is rather straightforward to fork the extension and create a new one. However, that will certainly cause problems in environments where the standard GMP extension is installed instead.

Full Operator Overloading

Some people on the list have voiced their opinion that we should revisit full operator overloading. This RFC does not prevent us from revisiting that in the future, but it is outside the scope of this RFC.

More Operators

There are many more operators that could be implemented. However, another concern was raised that people may “abuse” this feature to implement arbitrary objects that do strange things with operators. Thus, the scope is kept to “obviously integer-y things,” and the GMP extension goes to great lengths to have the engine treat its objects as numbers.

rfc/operator_overrides_lite.txt · Last modified: 2024/06/30 06:47 by withinboredom