Skip to content

Instantly share code, notes, and snippets.

@dalabarge dalabarge/Collection.php
Last active Jun 19, 2018

Embed
What would you like to do?
Generics in PHP
<?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;
}
}
<?php
namespace Generic;
interface Contract {
/**
* Make a new generic based on class.
*
* @return \Generic\Contract
*/
public static function generic() : Contract;
}
<?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];
}
}
<?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;
}
}
<?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);
@dalabarge

This comment has been minimized.

Copy link
Owner Author

dalabarge commented Jun 19, 2018

See https://github.com/artisansdk/generic for a more robust solution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.