Last active
June 19, 2018 20:56
-
-
Save dalabarge/20e660f1e3c3bbd7aeeda632b5d55bf3 to your computer and use it in GitHub Desktop.
Generics in PHP
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Generic; | |
class Collection implements Contract | |
{ | |
/** | |
* Untyped items in collection. | |
* | |
* @var array | |
*/ | |
private $items = []; | |
/** | |
* Make a new generic based on class. | |
* | |
* @param mixed $item for generic collection type | |
* | |
* @return \Generic\Contract | |
*/ | |
public static function generic() : Contract | |
{ | |
list($item) = func_get_args(); | |
return new Generic(static::class, $item); | |
} | |
/** | |
* Get all the untyped items in the collection. | |
* | |
* @return array | |
*/ | |
public function all() : array | |
{ | |
return $this->items; | |
} | |
/** | |
* Add untyped item to collection. | |
* | |
* @param mixed $item | |
* | |
* @return self | |
*/ | |
public function add($item) | |
{ | |
$this->items[] = $item; | |
return $this; | |
} | |
/** | |
* Remove untyped item from collection. | |
* | |
* @param mixed $item | |
* | |
* @return self | |
*/ | |
public function remove($item) | |
{ | |
$index = array_search($item, $this->items, true); | |
if( false !== $index ) { | |
unset($this->items, $index); | |
} | |
return $this; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Generic; | |
interface Contract { | |
/** | |
* Make a new generic based on class. | |
* | |
* @return \Generic\Contract | |
*/ | |
public static function generic() : Contract; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Generic; | |
use InvalidArgumentException; | |
use BadMethodCallException; | |
use ReflectionClass; | |
use ReflectionException; | |
use ReflectionMethod; | |
final class Generic implements Contract | |
{ | |
/** | |
* Map of method parameter positions to argument types. | |
* | |
* @example Generic::make($concrete, 'int', 'string') --> ['foo' => [0 => 'int', 1 => 'string']] | |
* | |
* @var array | |
*/ | |
private $methods = []; | |
/** | |
* The underlying untyped concrete. | |
* | |
* @var \Generic\Contract | |
*/ | |
private $concrete; | |
/** | |
* Flag for if generic type checking is enabled. | |
* | |
* @var bool | |
*/ | |
private $enabled; | |
/** | |
* The built-in types allowed for a generic. | |
* | |
* @var string | |
*/ | |
const TYPE_ARRAY = 'array'; | |
const TYPE_BOOL = 'boolean'; | |
const TYPE_BOOLEAN = 'boolean'; | |
const TYPE_CALLABLE = 'callable'; | |
const TYPE_DOUBLE = 'float'; | |
const TYPE_FLOAT = 'float'; | |
const TYPE_INT = 'integer'; | |
const TYPE_INTEGER = 'integer'; | |
const TYPE_NULL = 'null'; | |
const TYPE_RESOURCE = 'resource'; | |
const TYPE_STRING = 'string'; | |
/** | |
* The environment variable that enables generic type checking. | |
* | |
* @var string | |
*/ | |
const ENV_VARIABLE = 'PHP_GENERICS_ENABLED'; | |
/** | |
* Construct the generic as a typed proxy to the untyped concrete. | |
* | |
* @param mixed $concrete | |
* @param mixed $types | |
* | |
* @throws \ReflectionException when method is missing parameter dockblocks | |
*/ | |
public function __construct($concrete, ...$types) | |
{ | |
// The untyped concrete could be anything from a string, object, | |
// or concrete type resolver (callable) so we need to resolve input | |
// to an instance of the concrete object. | |
$this->concrete = $this->resolveConcrete($concrete); | |
// Type checking can be slower than not type checking so if you want | |
// max performance in production, set the environment variable to false | |
// and that will disable generic type checking. You should have already | |
// ran type checks in CI/CD pipelines so any errors should have been caught. | |
if( $this->enabled() ) { | |
// The remaining arguments of the constructor are the types that need | |
// to be setup for when methods are called. Each argument defines a type | |
// for that positional argument. | |
$types = $this->resolveTypes($types); | |
// Using reflection on the generic interface we get the parameters | |
// names that correspond to the position of the resolved types. | |
$reflection = new ReflectionClass($this->concrete); | |
$method = $reflection->getMethod('generic'); | |
$params = $this->resolveParams($method); | |
if( empty($params) ) { | |
throw new ReflectionException(sprintf('Invalid docblock on %s::%s() method.', $method->class, $method->name)); | |
} | |
// Using reflection on the public methods of the concrete we create | |
// a method map of concrete method parameters to the resolved types. | |
foreach($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { | |
$this->methods[$method->name] = []; | |
foreach($this->resolveParams($method) as $offset => $name) { | |
$index = array_search($name, $params, true); | |
$this->methods[$method->name][$offset] = $types[$index]; | |
} | |
} | |
} | |
} | |
/** | |
* Forward calls to proxied untyped concrete. | |
* | |
* @param string $method | |
* @param array $args | |
* | |
* @throws \BadMethodCallException when call cannot be forwarded | |
* | |
* @return mixed | |
*/ | |
public function __call($method, $args = []) | |
{ | |
if( method_exists($this->concrete, $method) ) { | |
if( $this->enabled() ) { | |
foreach($args as $index => $value ) { | |
// Only assert the type matches if the argument for the method | |
// was templated as a generic parameter type. | |
if( isset($this->methods[$method][$index])) { | |
$this->assertTypeMatches($this->methods[$method][$index], $value); | |
} | |
} | |
} | |
return call_user_func_array([$this->concrete, $method], $args); | |
} | |
throw new BadMethodCallException(sprintf('Generic %s::%s() method does not exist.', get_class($this->concrete), $method)); | |
} | |
/** | |
* Make a new generic based on class. | |
* | |
* @param mixed $concrete for generic | |
* @param mixed $types for generic concrete | |
* | |
* @return \Generic\Contract | |
*/ | |
public static function make($concrete, ...$types) : Contract | |
{ | |
return new static($concrete, ...$types); | |
} | |
/** | |
* Make a new generic based on class. | |
* | |
* @param mixed $concrete for generic | |
* @param mixed $types for generic concrete | |
* | |
* @return \Generic\Contract | |
*/ | |
public static function generic() : Contract | |
{ | |
$args = func_get_args(); | |
$concrete = array_shift($args); | |
return static::make($concrete, ...$args); | |
} | |
/** | |
* Determine if generic type checks are enabled. | |
* | |
* @return bool | |
*/ | |
private function enabled() : bool | |
{ | |
if( is_null($this->enabled) ) { | |
$this->enabled = (bool) getenv(static::ENV_VARIABLE); | |
} | |
return $this->enabled; | |
} | |
/** | |
* Assert that the type at the index position matches the type required. | |
* | |
* @param string $expected type of argument | |
* @param mixed $value of argument to be type matched | |
* | |
* @throws \InvalidArgumentException when type does not match | |
* | |
* @return void | |
*/ | |
private function assertTypeMatches(string $expected, $value) : void | |
{ | |
$actual = is_string($value) ? static::TYPE_STRING : $this->resolveType($value); | |
if( $expected !== $actual ) { | |
throw new InvalidArgumentException(sprintf('Expecting %s type argument but received %s instead.', $expected, $actual)); | |
} | |
} | |
/** | |
* Resolve the untyped concrete from a mixed argument. | |
* | |
* @param mixed $concrete to resolve | |
* | |
* @return \Generic\Contract | |
*/ | |
private function resolveConcrete($concrete) : Contract | |
{ | |
// Untyped concrete was passed in already constructed. | |
if( is_object($concrete) ) { | |
return $concrete; | |
} | |
// A resolver was passed which should make the untyped concrete. | |
if( is_callable($concrete) ) { | |
return $this->resolveConcrete($concrete()); | |
} | |
// An assumed class name was passed to construct the untyped concrete. | |
// If the untyped concrete is not implement the generic contract then | |
// the concrete will be constructed but the return type check of this | |
// method will fail and that will ensure type consistency. | |
return new $concrete(); | |
} | |
/** | |
* Resolve all the types to their position in the array. | |
* | |
* @param array $types to be resolved | |
* | |
* @return array | |
*/ | |
private function resolveTypes(array $types = []) : array | |
{ | |
$resolved = []; | |
foreach($types as $index => $type) { | |
$resolved[$index] = $this->resolveType($type); | |
} | |
return $resolved; | |
} | |
/** | |
* Resolve a mixed type to a built-in type or assumed class name. | |
* | |
* @param mixed $type to resolve | |
* | |
* @throws \InvalidArgumentException when generic type is not supported. | |
* | |
* @return string | |
*/ | |
private function resolveType($type) : string | |
{ | |
// Pass an object, get back the FQNS as custom type. | |
if( is_object($type) ) { | |
return get_class($type); | |
} | |
// Pass an array, get back the array built in type. | |
if( is_array($type) ) { | |
return static::TYPE_ARRAY; | |
} | |
// Pass a callable, get back the callable built in type. | |
if( is_callable($type) ) { | |
return static::TYPE_CALLABLE; | |
} | |
// Pass a boolean, get back the boolean built in type. | |
if( is_bool($type) ) { | |
return static::TYPE_BOOLEAN; | |
} | |
// Pass an integer, get back the integer built in type. | |
if( is_int($type) ) { | |
return static::TYPE_INT; | |
} | |
// Pass a float, get back the float built in type. | |
if( is_float($type) ) { | |
return static::TYPE_FLOAT; | |
} | |
// Pass a null, get back the null built in type. | |
if( is_null($type) ) { | |
return static::TYPE_NULL; | |
} | |
// Pass a string, get back the string as a custom type. | |
// If you pass "string" as the type then it's the same | |
// as passing in the built in type for string. | |
if( is_string($type) ) { | |
return $type; | |
} | |
// Pass a resource, get back the resource built in type. | |
if( is_resource($type) ) { | |
return static::TYPE_RESOURCE; | |
} | |
// We could create a macro ability for the generic to resolve custom | |
// types using a generic type resolver interface. For now we throw exception. | |
throw new InvalidArgumentException('Generic type given is not supported.'); | |
} | |
/** | |
* Resolve the parameters from the docblocks for the reflection method. | |
* | |
* @param \ReflectionMethod $method | |
* | |
* @return array | |
*/ | |
private function resolveParams(ReflectionMethod $method) : array | |
{ | |
preg_match_all('/\@param[\s]+[\w]+[\s]+\$([\w]+)/', $method->getDocComment(), $matches); | |
return $matches[1]; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
namespace Generic; | |
use InvalidArgumentException; | |
class HashMap implements Contract | |
{ | |
/** | |
* Untyped hash map. | |
* | |
* @var array | |
*/ | |
private $map = []; | |
/** | |
* Make a new generic based on class. | |
* | |
* @param mixed $key type for generic hash map | |
* @param mixed $value type for generic hash map | |
* | |
* @return \Generic\Contract | |
*/ | |
public static function generic() : Contract | |
{ | |
list($key, $value) = func_get_args(); | |
return new Generic(static::class, $key, $value); | |
} | |
/** | |
* Get the untyped map. | |
* | |
* @return array | |
*/ | |
public function all() : array | |
{ | |
return $this->map; | |
} | |
/** | |
* Get untyped hash map value by key. | |
* | |
* @param mixed $key | |
* | |
* @throws \InvalidArgumentException when key is not set in map. | |
* | |
* @return mixed|null | |
*/ | |
public function get($key) | |
{ | |
if( isset($this->map[$key]) ) { | |
return $this->map[$key]; | |
} | |
throw new InvalidArgumentException(sprintf('The key %s is not set in the map.', (string) $key)); | |
} | |
/** | |
* Set untyped hash map value by key. | |
* | |
* @param mixed $key | |
* @param mixed $value | |
* | |
* @return self | |
*/ | |
public function set($key, $value) | |
{ | |
$this->map[$key] = $value; | |
return $this; | |
} | |
/** | |
* Unset untyped hash map value by key. | |
* | |
* @param mixed $key | |
* | |
* @return self | |
*/ | |
public function unset($key) | |
{ | |
unset($this->map[$key]); | |
return $this; | |
} | |
/** | |
* Lookup untyped hash map value to get the key. | |
* | |
* @param mixed $value | |
* | |
* @throws \InvalidArgumentException when value is not found in map. | |
* | |
* @return mixed | |
*/ | |
public function key($value) | |
{ | |
$key = array_search($value, $this->map, true); | |
if( $key === false ) { | |
throw new InvalidArgumentException('The value is not set in the map.'); | |
} | |
return $key; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
// Run php -d memory_limit=256M Test.php if you need more memory | |
require_once('Contract.php'); | |
require_once('Generic.php'); | |
require_once('Collection.php'); | |
require_once('HashMap.php'); | |
use Generic\Contract; | |
use Generic\Generic; | |
use Generic\HashMap; | |
use Generic\Collection; | |
// Toggle this between 1 and 0 to see that exceptions are and are not checked | |
putenv(Generic::ENV_VARIABLE.'=1'); | |
class Duck {} | |
class User {} | |
// Construct a user hash map with the generic long hand | |
$users = new Generic(HashMap::class, Generic::TYPE_STRING, User::class); | |
// Set the user at the foo key in the hash map | |
$users->set('foo', new User()); | |
// Demonstrates that type checking is based on reflected param names | |
$user = new User(); | |
$users->set('bar', $user); | |
$users->key($user); | |
// Setting a duck is not allowed in a user hash map | |
// Expecting User type argument but received Duck instead. | |
try { $users->set('foo', new Duck()); } catch (InvalidArgumentException $e) { echo $e->getMessage().PHP_EOL;} | |
// Setting an integer key is not allowed in a string key hash map | |
// Expecting string type argument but received integer instead. | |
try { $users->set(0, new User()); } catch (InvalidArgumentException $e) { echo $e->getMessage().PHP_EOL;} | |
// Make a duck hash map with the generic long hand | |
$ducks = Generic::make(HashMap::class, Generic::TYPE_INT, Duck::class); | |
// Set the duck at the 0 index in the hash map | |
$ducks->set(0, new Duck()); | |
// Construct a user collection with the generic long hand | |
$users = new Generic(Collection::class, User::class); | |
$users->add(new User()); | |
// Adding a duck to a user collection will fail | |
// Expecting User type argument but received Duck instead. | |
try { $users->add(new Duck()); } catch (InvalidArgumentException $e) { echo $e->getMessage().PHP_EOL;} | |
// Make a duck collection with the generic short hand | |
$ducks = Collection::generic(Duck::class); | |
// Add a duck to the duck collection | |
$ducks->add(new Duck()); | |
// Performance test for the cost of constructing generics | |
// Disabled: 1.6158246994019E-6 seconds average | |
// Enabled: 1.6025094985962E-5 seconds average | |
// Delta: 0.00001440927029 seconds (0.014ms) | |
$timers = []; | |
$generics = []; | |
for($i=0;$i<100000;$i++) { | |
$time = microtime(true); | |
$generics[] = HashMap::generic(Generic::TYPE_INT, new stdClass()); | |
$timer = microtime(true) - $time; | |
$timers[] = $timer; | |
} | |
print_r('= '.array_sum($timers) / count($timers).' seconds average'.PHP_EOL); | |
// Performance test for the cost of type checking at call time | |
// Disabled: 0.27702808380127 seconds average | |
// Enabled: 0.29283628463745 seconds average | |
// Delta: 0.01580820084 seconds (15.808ms) | |
$timers = []; | |
$generic = HashMap::generic(Generic::TYPE_INT, new stdClass()); | |
for($i=0;$i<10;$i++) { | |
$time = microtime(true); | |
for($x=0;$x<10000;$x++) { | |
$obj = new stdClass(); | |
$generic->set($x, $obj); | |
$generic->get($x); | |
$generic->key($obj); | |
$generic->all(); | |
} | |
$timer = microtime(true) - $time; | |
$timers[] = $timer; | |
print_r(($i+1).' > '.$timer.' seconds'.PHP_EOL); | |
} | |
print_r('= '.array_sum($timers) / count($timers).' seconds average'.PHP_EOL); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
See https://github.com/artisansdk/generic for a more robust solution.