├── LICENSE ├── README.md ├── composer.json └── src ├── CandidatesFinder.php ├── CodeGenerator.php ├── FileResolverCache.php ├── InMemoryResolverCache.php ├── Overload.php └── ResolverCache.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-present Valentin Udaltsov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typhoon Overloading 2 | 3 | The missing method overloading feature for PHP. 4 | 5 | [![PHP Version Require](http://poser.pugx.org/typhoon/overloading/require/php)](https://packagist.org/packages/typhoon/overloading) 6 | [![Latest Stable Version](https://poser.pugx.org/typhoon/overloading/v/stable.png)](https://packagist.org/packages/typhoon/overloading) 7 | [![Total Downloads](https://poser.pugx.org/typhoon/overloading/downloads.png)](https://packagist.org/packages/typhoon/overloading) 8 | [![psalm-level](https://shepherd.dev/github/typhoon-php/overloading/level.svg)](https://shepherd.dev/github/typhoon-php/overloading) 9 | [![type-coverage](https://shepherd.dev/github/typhoon-php/overloading/coverage.svg)](https://shepherd.dev/github/typhoon-php/overloading) 10 | [![Code Coverage](https://codecov.io/gh/typhoon-php/overloading/branch/0.1.x/graph/badge.svg)](https://codecov.io/gh/typhoon-php/overloading/tree/0.1.x) 11 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Ftyphoon-php%2Foverloading%2F0.1.x)](https://dashboard.stryker-mutator.io/reports/github.com/typhoon-php/overloading/0.1.x) 12 | 13 | ## Installation 14 | 15 | `composer require typhoon/overloading` 16 | 17 | ## Usage 18 | 19 | To mark methods `handleInt` and `handleString` as overloading method `handle`, 20 | add `#[Overload('handle')]` attribute to `handleInt` and `handleString` and 21 | call `Overload::call()` from `handle`. You do not need to pass arguments to `Overload::call()`, 22 | this happens automagically. However, return `Overload::call()` explicitly if you need to. 23 | After this you will be able to call `handle` with any arguments and reach overloading methods 24 | when their signature matches. 25 | 26 | ```php 27 | use Typhoon\Overloading\Overload; 28 | 29 | final class WhateverHandler 30 | { 31 | public function handle(mixed ...$args): string 32 | { 33 | return Overload::call(); 34 | } 35 | 36 | #[Overload('handle')] 37 | public function handleInt(int $int): string 38 | { 39 | return __METHOD__; 40 | } 41 | 42 | #[Overload('handle')] 43 | public function handleString(string $string): string 44 | { 45 | return __METHOD__; 46 | } 47 | 48 | #[Overload('handle')] 49 | public function handleStdClass(\stdClass $object): string 50 | { 51 | return __METHOD__; 52 | } 53 | 54 | #[Overload('handle')] 55 | public function handleNamedOptionalArguments(int $int = 0, float $float = M_E): string 56 | { 57 | return __METHOD__; 58 | } 59 | } 60 | 61 | $handler = new WhateverHandler(); 62 | 63 | // WhateverHandler::handleInt 64 | var_dump($handler->handle(300)); 65 | 66 | // WhateverHandler::handleString 67 | var_dump($handler->handle('Hello world!')); 68 | 69 | // WhateverHandler::handleStdClass 70 | var_dump($handler->handle(new \stdClass())); 71 | 72 | // WhateverHandler::handleNamedOptionalArguments 73 | var_dump($handler->handle(float: 1.5)); 74 | 75 | // WhateverHandler::handleNamedOptionalArguments 76 | var_dump($handler->handle()); 77 | 78 | // No matching overloading methods for WhateverHandler::handle(string, bool). 79 | var_dump($handler->handle('Hey!', true)); 80 | ``` 81 | 82 | ## What about speed? 83 | 84 | Well, using overloading is obviously slower, than calling a method directly, but not awfully slower. 85 | Here's a simple benchmark for the `WhateverHandler`: 86 | 87 | ```php 88 | // warm up 89 | $handler->handle(); 90 | 91 | \DragonCode\Benchmark\Benchmark::start() 92 | ->withoutData() 93 | ->round(2) 94 | ->compare([ 95 | 'direct call' => static fn (): string => $handler->handleNamedOptionalArguments(), 96 | 'overloaded call' => static fn (): string => $handler->handle(), 97 | ]); 98 | ``` 99 | 100 | ```shell 101 | ------- ---------------- ------------------- 102 | # direct call overloaded call 103 | ------- ---------------- ------------------- 104 | min 0 ms - 0 bytes 0 ms - 0 bytes 105 | max 0 ms - 0 bytes 0.02 ms - 0 bytes 106 | avg 0 ms - 0 bytes 0 ms - 0 bytes 107 | total 0.95 ms 1.16 ms 108 | ------- ---------------- ------------------- 109 | Order - 1 - - 2 - 110 | ------- ---------------- ------------------- 111 | ``` 112 | 113 | It's important to understand that memoization plays a very important role here. CLI workers and applications, served 114 | via Roadrunner, for instance, will benefit from this. For PHP-FPM you can enable file cache suitable for OPcaching via 115 | `Overload::useFileCache('/path/to/cache');`. 116 | 117 | ## TODO 118 | 119 | - [ ] Finish tests. 120 | - [ ] Explain caching in README. 121 | - [ ] Optimize generated code. 122 | - [ ] Inherit attributes from upstream method declarations. 123 | - [ ] Allow to warm up classes. 124 | - [ ] Psalm plugin. 125 | - [ ] PHPStan plugin. 126 | - [ ] Support static analysis types. 127 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typhoon/overloading", 3 | "description": "The missing method overloading feature for PHP.", 4 | "license": "MIT", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Valentin Udaltsov", 9 | "email": "udaltsov.valentin@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "ext-filter": "*" 15 | }, 16 | "require-dev": { 17 | "dragon-code/benchmark": "^2.5", 18 | "ergebnis/composer-normalize": "^2.39", 19 | "friendsofphp/php-cs-fixer": "^3.38.2", 20 | "icanhazstring/composer-unused": "^0.8.10", 21 | "infection/infection": "^0.27.8", 22 | "maglnet/composer-require-checker": "^4.7.1", 23 | "phpunit/phpunit": "^10.4.2", 24 | "phpyh/coding-standard": "^2.5.0", 25 | "psalm/plugin-phpunit": "^0.18.4", 26 | "rector/rector": "^0.18.10", 27 | "symfony/filesystem": "^6.3", 28 | "symfony/var-dumper": "^6.3.8", 29 | "vimeo/psalm": "^5.15.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Typhoon\\Overloading\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Typhoon\\Overloading\\": "tests/" 39 | } 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "ergebnis/composer-normalize": true, 44 | "infection/extension-installer": true 45 | }, 46 | "sort-packages": true 47 | }, 48 | "scripts": { 49 | "check-require": "composer-require-checker check --config-file=composer-require-checker.json", 50 | "check-unused": "composer-unused", 51 | "fixcs": "php-cs-fixer fix --diff --verbose", 52 | "infection": "infection --threads=max --show-mutations", 53 | "pre-command-run": "mkdir -p var", 54 | "psalm": "psalm --show-info=true --no-diff", 55 | "rector": "rector process", 56 | "test": "phpunit" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/CandidatesFinder.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public static function findCandidates(\ReflectionMethod $method, string $class): array 24 | { 25 | $static = $method->isStatic(); 26 | $private = $method->isPrivate(); 27 | $protected = $method->isProtected(); 28 | $candidates = []; 29 | 30 | foreach (self::findCandidatesByAttribute($method, $class) as $candidate) { 31 | if ($candidate->isStatic() !== $static) { 32 | throw new \LogicException(sprintf( 33 | '%s %s::%s() is not a valid overloading method for %s %s::%s().', 34 | $candidate->isStatic() ? 'Static' : 'Non-static', 35 | $candidate->class, 36 | $candidate->name, 37 | $static ? 'static' : 'non-static', 38 | $method->class, 39 | $method->name, 40 | )); 41 | } 42 | 43 | if ($candidate->isPrivate() !== $private || $candidate->isProtected() !== $protected) { 44 | throw new \LogicException(sprintf( 45 | '%s %s::%s() is not a valid overloading method for %s %s::%s().', 46 | $candidate->isPrivate() ? 'Private' : ($candidate->isProtected() ? 'Protected' : 'Public'), 47 | $candidate->class, 48 | $candidate->name, 49 | $private ? 'private' : ($protected ? 'protected' : 'public'), 50 | $method->class, 51 | $method->name, 52 | )); 53 | } 54 | 55 | $candidates[] = $candidate; 56 | } 57 | 58 | return $candidates; 59 | } 60 | 61 | /** 62 | * @param class-string $class 63 | * @return \Generator 64 | */ 65 | private static function findCandidatesByAttribute(\ReflectionMethod $method, string $class): \Generator 66 | { 67 | foreach ((new \ReflectionClass($class))->getMethods() as $candidate) { 68 | if ($candidate->name === $method->name) { 69 | continue; 70 | } 71 | 72 | foreach ($candidate->getAttributes(Overload::class) as $attribute) { 73 | if ($attribute->newInstance()->name === $method->name) { 74 | yield $candidate; 75 | 76 | continue 2; 77 | } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/CodeGenerator.php: -------------------------------------------------------------------------------- 1 | '%s === null', 15 | 'true' => '%s === true', 16 | 'false' => '%s === false', 17 | 'bool' => 'is_bool(%s)', 18 | 'int' => 'is_int(%s)', 19 | 'float' => 'is_float(%s)', 20 | 'string' => 'is_string(%s)', 21 | 'array' => 'is_array(%s)', 22 | 'object' => 'is_object(%s)', 23 | 'resource' => 'is_resource(%s)', 24 | 'iterable' => 'is_iterable(%s)', 25 | 'callable' => 'is_callable(%s)', 26 | 'static' => '%s instanceof static', 27 | 'mixed' => 'true', 28 | ]; 29 | 30 | /** 31 | * @psalm-suppress UnusedConstructor 32 | * @codeCoverageIgnore 33 | */ 34 | private function __construct() {} 35 | 36 | /** 37 | * @param ?class-string $class 38 | */ 39 | public static function generateTypeCheck(?string $class, string $var, ?\ReflectionType $type): string 40 | { 41 | if ($type === null) { 42 | return 'true'; 43 | } 44 | 45 | if ($type instanceof \ReflectionUnionType) { 46 | return sprintf('(%s)', implode(' || ', array_map( 47 | static fn (\ReflectionType $type): string => self::generateTypeCheck($class, $var, $type), 48 | $type->getTypes(), 49 | ))); 50 | } 51 | 52 | if ($type instanceof \ReflectionIntersectionType) { 53 | return implode(' && ', array_map( 54 | static fn (\ReflectionType $type): string => self::generateTypeCheck($class, $var, $type), 55 | $type->getTypes(), 56 | )); 57 | } 58 | 59 | if (!$type instanceof \ReflectionNamedType) { 60 | throw new \LogicException(sprintf('%s is not supported.', $type::class)); 61 | } 62 | 63 | $name = $type->getName(); 64 | 65 | if ($name === 'self') { 66 | if ($class === null) { 67 | throw new \LogicException('No scope class.'); 68 | } 69 | 70 | return $var . ' instanceof ' . $class; 71 | } 72 | 73 | if ($name === 'parent') { 74 | if ($class === null) { 75 | throw new \LogicException('No scope class.'); 76 | } 77 | 78 | $parent = get_parent_class($class); 79 | 80 | if ($parent === false) { 81 | throw new \LogicException(sprintf('%s does not have parent.', $class)); 82 | } 83 | 84 | return $var . ' instanceof ' . $parent; 85 | } 86 | 87 | $code = isset(self::TYPE_CHECKERS[$name]) ? sprintf(self::TYPE_CHECKERS[$name], $var) : sprintf('%s instanceof \%s', $var, $name); 88 | 89 | if ($type->allowsNull() && $name !== 'null' && $name !== 'mixed') { 90 | return sprintf('(%s === null || %s)', $var, $code); 91 | } 92 | 93 | return $code; 94 | } 95 | 96 | public static function generateArgumentsNumberCheck(\ReflectionFunctionAbstract $function): string 97 | { 98 | $numberOfParameters = $function->getNumberOfParameters(); 99 | $numberOfRequiredParameters = $function->getNumberOfRequiredParameters(); 100 | 101 | if ($numberOfParameters === $numberOfRequiredParameters) { 102 | return '$argsNumber === ' . $numberOfParameters; 103 | } 104 | 105 | $code = ''; 106 | 107 | if ($numberOfRequiredParameters > 0) { 108 | $code = '$argsNumber >= ' . $numberOfRequiredParameters; 109 | } 110 | 111 | if ($function->isVariadic()) { 112 | return $code; 113 | } 114 | 115 | return ($code === '' ? '' : $code . ' && ') . '$argsNumber <= ' . $numberOfParameters; 116 | } 117 | 118 | public static function generateArgumentsCheck(\ReflectionFunctionAbstract $function): string 119 | { 120 | $class = $function instanceof \ReflectionMethod ? $function->class : $function->getClosureScopeClass()?->name; 121 | $code = self::generateArgumentsNumberCheck($function); 122 | 123 | foreach ($function->getParameters() as $parameter) { 124 | $position = $parameter->getPosition(); 125 | $name = var_export($parameter->name, true); 126 | $var = sprintf('($args[%d] ?? $args[%s])', $position, $name); 127 | $typeCheck = self::generateTypeCheck($class, $var, $parameter->getType()); 128 | 129 | if ($parameter->isOptional()) { 130 | $code .= sprintf( 131 | ' && (!array_key_exists(%d, $args) && !array_key_exists(%s, $args) || %s)', 132 | $position, 133 | $name, 134 | $typeCheck, 135 | ); 136 | 137 | continue; 138 | } 139 | 140 | $code .= sprintf( 141 | ' && (array_key_exists(%d, $args) || array_key_exists(%s, $args)) && %s', 142 | $position, 143 | $name, 144 | $typeCheck, 145 | ); 146 | } 147 | 148 | return $code; 149 | } 150 | 151 | /** 152 | * @param non-empty-list<\ReflectionFunctionAbstract> $candidates 153 | */ 154 | public static function generateResolver(\ReflectionFunctionAbstract $function, array $candidates): string 155 | { 156 | $code = ($function instanceof \ReflectionFunction || $function->isStatic() ? 'static ' : '') . "function (\$args) {\n"; 157 | $code .= " \$argsNumber = count(\$args);\n"; 158 | 159 | foreach ($candidates as $candidate) { 160 | $code .= sprintf( 161 | "\n if (%s) {\n return %s%s(%s);\n }\n", 162 | self::generateArgumentsCheck($candidate), 163 | $candidate instanceof \ReflectionMethod ? ($candidate->isStatic() ? 'self::' : '$this->') : '', 164 | $candidate->name, 165 | $candidate->getNumberOfParameters() === 0 ? '' : '...$args', 166 | ); 167 | } 168 | 169 | return $code . sprintf( 170 | "\n throw new \\BadMethodCallException(sprintf('No matching overloading %s for %s(%%s).', implode(', ', array_map('get_debug_type', \$args))));\n}", 171 | $function instanceof \ReflectionFunction ? 'functions' : 'methods', 172 | self::functionName($function), 173 | ); 174 | } 175 | 176 | private static function functionName(\ReflectionFunctionAbstract $function): string 177 | { 178 | return sprintf('%s%s', $function instanceof \ReflectionMethod ? $function->class . '::' : '', $function->name); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/FileResolverCache.php: -------------------------------------------------------------------------------- 1 | directory = $directory ?? sys_get_temp_dir() . \DIRECTORY_SEPARATOR . 'typhoon_overload' . \DIRECTORY_SEPARATOR . hash('xxh128', __DIR__); 22 | } 23 | 24 | /** 25 | * @psalm-suppress MixedArgument 26 | * @infection-ignore-all 27 | */ 28 | private static function opcacheEnabled(): bool 29 | { 30 | return self::$opcacheEnabled ??= (\function_exists('opcache_invalidate') 31 | && filter_var(\ini_get('opcache.enable'), FILTER_VALIDATE_BOOL) 32 | && (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true) || filter_var(\ini_get('opcache.enable_cli'), FILTER_VALIDATE_BOOL))); 33 | } 34 | 35 | public function get(string $class, string $method, callable $codeGenerator): \Closure 36 | { 37 | return $this->handleErrors(function () use ($class, $method, $codeGenerator): \Closure { 38 | $file = $this->file($class, $method); 39 | 40 | try { 41 | /** 42 | * @psalm-suppress UnresolvableInclude 43 | * @var Resolver 44 | */ 45 | return include $file; 46 | } catch (\Throwable $exception) { 47 | if (!str_contains($exception->getMessage(), 'No such file or directory')) { 48 | throw $exception; 49 | } 50 | } 51 | 52 | $directory = \dirname($file); 53 | 54 | if (!is_dir($directory)) { 55 | mkdir($directory, recursive: true); 56 | } 57 | 58 | /** @infection-ignore-all */ 59 | $tmp = $directory . uniqid(more_entropy: true); 60 | $handle = fopen($tmp, 'x'); 61 | fwrite($handle, "handleErrors(function (): void { 88 | if (!is_dir($this->directory)) { 89 | return; 90 | } 91 | 92 | /** @var iterable<\SplFileInfo> */ 93 | $iterator = new \RecursiveIteratorIterator( 94 | new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), 95 | \RecursiveIteratorIterator::CHILD_FIRST, 96 | ); 97 | 98 | foreach ($iterator as $file) { 99 | $pathname = $file->getPathname(); 100 | 101 | if ($file->isDir()) { 102 | rmdir($pathname); 103 | 104 | continue; 105 | } 106 | 107 | try { 108 | unlink($pathname); 109 | } catch (\Throwable $exception) { 110 | if (!str_contains($exception->getMessage(), 'No such file or directory')) { 111 | throw $exception; 112 | } 113 | 114 | continue; 115 | } 116 | 117 | if (self::opcacheEnabled()) { 118 | opcache_invalidate($pathname, true); 119 | } 120 | } 121 | }); 122 | } 123 | 124 | /** 125 | * @param class-string $class 126 | * @param non-empty-string $method 127 | */ 128 | private function file(string $class, string $method): string 129 | { 130 | $hash = hash('xxh128', $class . '::' . $method); 131 | 132 | /** @infection-ignore-all */ 133 | return $this->directory . \DIRECTORY_SEPARATOR . $hash[0] . \DIRECTORY_SEPARATOR . $hash[1] . \DIRECTORY_SEPARATOR . substr($hash, 2) . '.php'; 134 | } 135 | 136 | /** 137 | * @template T 138 | * @param \Closure(): T $function 139 | * @return T 140 | */ 141 | private function handleErrors(\Closure $function): mixed 142 | { 143 | set_error_handler(static fn (int $level, string $message, string $file, int $line) => throw new \ErrorException( 144 | message: $message, 145 | severity: $level, 146 | filename: $file, 147 | line: $line, 148 | )); 149 | 150 | try { 151 | return $function(); 152 | } finally { 153 | restore_error_handler(); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/InMemoryResolverCache.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private array $resolvers = []; 18 | 19 | public function get(string $class, string $method, callable $codeGenerator): \Closure 20 | { 21 | $key = $class . '::' . $method; 22 | 23 | if (isset($this->resolvers[$key])) { 24 | return $this->resolvers[$key]; 25 | } 26 | 27 | /** 28 | * It is completely safe to use eval here, since there's no user import involved in $codeGenerator. 29 | * $codeGenerator produces code purely based on Reflection. 30 | * 31 | * @psalm-suppress ForbiddenCode 32 | * @var Resolver 33 | */ 34 | $resolver = eval('return ' . $codeGenerator() . ';'); 35 | 36 | return $this->resolvers[$key] = $resolver; 37 | } 38 | 39 | public function clear(): void 40 | { 41 | $this->resolvers = []; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Overload.php: -------------------------------------------------------------------------------- 1 | get($class, $method, static function () use ($class, $method): string { 40 | $reflectionMethod = new \ReflectionMethod($class, $method); 41 | $candidates = CandidatesFinder::findCandidates($reflectionMethod, $class); 42 | 43 | if ($candidates === []) { 44 | throw new \BadMethodCallException(sprintf('No overloading methods for %s::%s().', $class, $method)); 45 | } 46 | 47 | return CodeGenerator::generateResolver($reflectionMethod, $candidates); 48 | }) 49 | ->bindTo($object, $class) 50 | ->__invoke($trace['args'] ?? []); 51 | } 52 | 53 | public static function useFileCache(?string $directory = null): void 54 | { 55 | self::$resolverCache = new FileResolverCache($directory); 56 | } 57 | 58 | public static function useInMemoryCache(): void 59 | { 60 | self::$resolverCache = null; 61 | } 62 | 63 | public static function clearCache(): void 64 | { 65 | self::$resolverCache?->clear(); 66 | } 67 | 68 | /** 69 | * @param class-string $class 70 | * @param non-empty-string $method 71 | * @return class-string 72 | */ 73 | private static function resolveClass(string $class, string $method, ?object $object): string 74 | { 75 | if ($object === null) { 76 | return $class; 77 | } 78 | 79 | if ($object::class === $class) { 80 | return $class; 81 | } 82 | 83 | if ((new \ReflectionMethod($class, $method))->isPrivate()) { 84 | return $class; 85 | } 86 | 87 | return $object::class; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/ResolverCache.php: -------------------------------------------------------------------------------- 1 | ): mixed 11 | */ 12 | interface ResolverCache 13 | { 14 | /** 15 | * @param class-string $class 16 | * @param non-empty-string $method 17 | * @param callable(): string $codeGenerator 18 | * @return Resolver 19 | */ 20 | public function get(string $class, string $method, callable $codeGenerator): \Closure; 21 | 22 | public function clear(): void; 23 | } 24 | --------------------------------------------------------------------------------