Type hinting in PHP 7 - array of objects
Asked Answered
R

7

106

Maybe I missed something but is there any option to define that function should have argument or return for example array of User objects?

Consider the following code:

<?php

class User
{
    protected $name;

    protected $age;

    /**
     * User constructor.
     *
     * @param $name
     */
    public function __construct(string $name, int $age)
    {
        $this->name = $name;
        $this->age = $age;
    }

    /**
     * @return mixed
     */
    public function getName() : string
    {
        return $this->name;
    }

    public function getAge() : int
    {
        return $this->age;
    }
}

function findUserByAge(int $age, array $users) : array
{
    $result = [];
    foreach ($users as $user) {
        if ($user->getAge() == $age) {
            if ($user->getName() == 'John') {
                // complicated code here
                $result[] = $user->getName(); // bug
            } else {
                $result[] = $user;
            }
        }
    }

    return $result;
}

$users = [
    new User('John', 15),
    new User('Daniel', 25),
    new User('Michael', 15),
];

$matches = findUserByAge(15, $users);

foreach ($matches as $user) {
    echo $user->getName() . ' '.$user->getAge() . "\n";
}

Is there any option in PHP7 to tell function findUserByAge should return array of users? I would expect that when type hinting was added it should be possible but I haven't found any info for type hinting for array of objects so probably it's not included in PHP 7. If it's not included, do you have Any clue why it was not included when type hinting was added?

Reason answered 14/12, 2015 at 17:44 Comment(1)
only by convention, e.g. with @return User[] in the DocBlockRequital
M
146

It's not included.

If it's not included, do you have Any clue why it was not included when type hinting was added?

With the current array implementation, it would require checking all array elements at runtime, because the array itself contains no type information.

It has actually already been proposed for PHP 5.6 but rejected: RFC "arrayof" - interestingly not because of performance issues which turned out to be neglible, but because there was no agreement in how exactly it should be implemented. There was also the objection that it is incomplete without scalar type hints. If you are interested in the whole discussion, read it in the mailing list archive.

IMHO array type hints would provide most benefit together with typed arrays, and I'd love to see them implemented.

So maybe it's about time for a new RFC and to reopen this discussion.


Partial Workaround:

you can type hint variadic arguments and thus write the signature as

function findUserByAge(int $age, User ...$users) : array

Usage:

findUserByAge(15, ...$userInput);

In this call, the argument $userInput will be "unpacked" into single variables, and in the method itself "packed" back into an array $users. Each item is validated to be of type User. $userInput can also be an iterator, it will be converted to an array.

Unfortunately there is no similar workaround for return types, and you can only use it for the last argument.

Macao answered 14/12, 2015 at 18:11 Comment(8)
how about 7.1, has it been included?Henig
@RobertLimanto no, and still not in sight for 7.2, have a look at wiki.php.net/rfc#php_next_72Macao
I would love to see a way to specify which type is within a returned array as well. I assume this would be nice for IDEs, since that should allow them to understand the type and thereby provide better support for autocompletion etc.Further, a stronger type hinting, while having the possibility to not specify types feels to me like a great combination.Impassion
For IDEs you can use phpDoc @return User[]Macao
Has somebody measured performance drawback when you use variadic arguments in displayed manner?Leger
@metamaker Variadic will be significant slower 3v4l.org/3hOfJ and can greatly impact memory usage with large collections. Due to the fact the collection must be unpacked as individual arguments and then packed again into a variable array. Goes without saying but the more objects in the collection, the greater the impact. And is still slower 3v4l.org/JMrsq even when iterating over the entire collection to validate the types before performing any business logicSimonnesimonpure
Maybe they could add a User[] (or Typescript style Array<User>) only for static validation if the lone concern is runtime performance. IMHO, it's awkward to rely on phpDoc just for array IDE assistant and classes member vars typization.Thompkins
that RFC is quite old, 2014. reading through the mailing list, two biggest problems was handling NULL values and absence of scalar type hints. both were already resolved in PHP 7/8. we have nullable type hints, so ?User[] would allow NULL values, while User[] would not. scalar type hints already exist, so int[] would work as well. i wonder why the php devs did not reconsider that RFC proposal according to current PHP features state.Grunberg
H
12

In our codebase, we have the concept of collections. These are based upon a class called TypedArray which is based upon ArrayObject.

class ArrayObject extends \ArrayObject
{
    /**
     * Clone a collection by cloning all items.
     */
    public function __clone()
    {
        foreach ($this as $key => $value) {
            $this[$key] = is_object($value) ? clone $value : $value;
        }
    }

    /**
     * Inserting the provided element at the index. If index is negative, it will be calculated from the end of the Array Object
     *
     * @param int $index
     * @param mixed $element
     */
    public function insert(int $index, $element)
    {
        $data = $this->getArrayCopy();
        if ($index < 0) {
            $index = $this->count() + $index;
        }

        $data = array_merge(array_slice($data, 0, $index, true), [$element], array_slice($data, $index, null, true));
        $this->exchangeArray($data);
    }

    /**
     * Remove a portion of the array and optionally replace it with something else.
     *
     * @see array_splice()
     *
     * @param int $offset
     * @param int|null $length
     * @param null $replacement
     *
     * @return static
     */
    public function splice(int $offset, int $length = null, $replacement = null)
    {
        $data = $this->getArrayCopy();

        // A null $length AND a null $replacement is not the same as supplying null to the call.
        if (is_null($length) && is_null($replacement)) {
            $result = array_splice($data, $offset);
        } else {
            $result = array_splice($data, $offset, $length, $replacement);
        }
        $this->exchangeArray($data);

        return new static($result);
    }

    /**
     * Adding a new value at the beginning of the collection
     *
     * @param mixed $value
     *
     * @return int Returns the new number of elements in the Array
     */
    public function unshift($value): int
    {
        $data = $this->getArrayCopy();
        $result = array_unshift($data, $value);
        $this->exchangeArray($data);

        return $result;
    }

    /**
     * Extract a slice of the array.
     *
     * @see array_slice()
     *
     * @param int $offset
     * @param int|null $length
     * @param bool $preserveKeys
     *
     * @return static
     */
    public function slice(int $offset, int $length = null, bool $preserveKeys = false)
    {
        return new static(array_slice($this->getArrayCopy(), $offset, $length, $preserveKeys));
    }

    /**
     * Sort an array.
     *
     * @see sort()
     *
     * @param int $sortFlags
     *
     * @return bool
     */
    public function sort($sortFlags = SORT_REGULAR)
    {
        $data = $this->getArrayCopy();
        $result = sort($data, $sortFlags);
        $this->exchangeArray($data);

        return $result;
    }

    /**
     * Apply a user supplied function to every member of an array
     *
     * @see array_walk
     *
     * @param callable $callback
     * @param mixed|null $userData
     *
     * @return bool Returns true on success, otherwise false
     *
     * @see array_walk()
     */
    public function walk($callback, $userData = null)
    {
        $data = $this->getArrayCopy();
        $result = array_walk($data, $callback, $userData);
        $this->exchangeArray($data);

        return $result;
    }

    /**
     * Chunks the object into ArrayObject containing
     *
     * @param int $size
     * @param bool $preserveKeys
     *
     * @return ArrayObject
     */
    public function chunk(int $size, bool $preserveKeys = false): ArrayObject
    {
        $data = $this->getArrayCopy();
        $result = array_chunk($data, $size, $preserveKeys);

        return new ArrayObject($result);
    }

    /**
     * @see array_column
     *
     * @param mixed $columnKey
     *
     * @return array
     */
    public function column($columnKey): array
    {
        $data = $this->getArrayCopy();
        $result = array_column($data, $columnKey);

        return $result;
    }

    /**
     * @param callable $mapper Will be called as $mapper(mixed $item)
     *
     * @return ArrayObject A collection of the results of $mapper(mixed $item)
     */
    public function map(callable $mapper): ArrayObject
    {
        $data = $this->getArrayCopy();
        $result = array_map($mapper, $data);

        return new self($result);
    }

    /**
     * Applies the callback function $callable to each item in the collection.
     *
     * @param callable $callable
     */
    public function each(callable $callable)
    {
        foreach ($this as &$item) {
            $callable($item);
        }
        unset($item);
    }

    /**
     * Returns the item in the collection at $index.
     *
     * @param int $index
     *
     * @return mixed
     *
     * @throws InvalidArgumentException
     * @throws OutOfRangeException
     */
    public function at(int $index)
    {
        $this->validateIndex($index);

        return $this[$index];
    }

    /**
     * Validates a number to be used as an index
     *
     * @param int $index The number to be validated as an index
     *
     * @throws OutOfRangeException
     * @throws InvalidArgumentException
     */
    private function validateIndex(int $index)
    {
        $exists = $this->indexExists($index);

        if (!$exists) {
            throw new OutOfRangeException('Index out of bounds of collection');
        }
    }

    /**
     * Returns true if $index is within the collection's range and returns false
     * if it is not.
     *
     * @param int $index
     *
     * @return bool
     *
     * @throws InvalidArgumentException
     */
    public function indexExists(int $index)
    {
        if ($index < 0) {
            throw new InvalidArgumentException('Index must be a non-negative integer');
        }

        return $index < $this->count();
    }

    /**
     * Finding the first element in the Array, for which $callback returns true
     *
     * @param callable $callback
     *
     * @return mixed Element Found in the Array or null
     */
    public function find(callable $callback)
    {
        foreach ($this as $element) {
            if ($callback($element)) {
                return $element;
            }
        }

        return null;
    }

    /**
     * Filtering the array by retrieving only these elements for which callback returns true
     *
     * @param callable $callback
     * @param int $flag Use ARRAY_FILTER_USE_KEY to pass key as the only argument to $callback instead of value.
     *                  Use ARRAY_FILTER_USE_BOTH pass both value and key as arguments to $callback instead of value.
     *
     * @return static
     *
     * @see array_filter
     */
    public function filter(callable $callback, int $flag = 0)
    {
        $data = $this->getArrayCopy();
        $result = array_filter($data, $callback, $flag);

        return new static($result);
    }

    /**
     * Reset the array pointer to the first element and return the element.
     *
     * @return mixed
     *
     * @throws \OutOfBoundsException
     */
    public function first()
    {
        if ($this->count() === 0) {
            throw new \OutOfBoundsException('Cannot get first element of empty Collection');
        }

        return reset($this);
    }

    /**
     * Reset the array pointer to the last element and return the element.
     *
     * @return mixed
     *
     * @throws \OutOfBoundsException
     */
    public function last()
    {
        if ($this->count() === 0) {
            throw new \OutOfBoundsException('Cannot get last element of empty Collection');
        }

        return end($this);
    }

    /**
     * Apply a user supplied function to every member of an array
     *
     * @see array_reverse
     *
     * @param bool $preserveKeys
     *
     * @return static
     */
    public function reverse(bool $preserveKeys = false)
    {
        return new static(array_reverse($this->getArrayCopy(), $preserveKeys));
    }

    public function keys(): array
    {
        return array_keys($this->getArrayCopy());
    }

    /**
     * Use a user supplied callback to reduce the array to a single member and return it.
     *
     * @param callable $callback
     * @param mixed|null $initial
     *
     * @return mixed
     */
    public function reduce(callable $callback, $initial = null)
    {
        return array_reduce($this->getArrayCopy(), $callback, $initial);
    }
}

and

/**
 * Class TypedArray
 *
 * This is a typed array
 *
 * By enforcing the type, you can guarantee that the content is safe to simply iterate and call methods on.
 */
abstract class AbstractTypedArray extends ArrayObject
{
    use TypeValidator;

    /**
     * Define the class that will be used for all items in the array.
     * To be defined in each sub-class.
     */
    const ARRAY_TYPE = null;

    /**
     * Array Type
     *
     * Once set, this ArrayObject will only accept instances of that type.
     *
     * @var string $arrayType
     */
    private $arrayType = null;

    /**
     * Constructor
     *
     * Store the required array type prior to parental construction.
     *
     * @param mixed[] $input Any data to preset the array to.
     * @param int $flags The flags to control the behaviour of the ArrayObject.
     * @param string $iteratorClass Specify the class that will be used for iteration of the ArrayObject object. ArrayIterator is the default class used.
     *
     * @throws InvalidArgumentException
     */
    public function __construct($input = [], $flags = 0, $iteratorClass = ArrayIterator::class)
    {
        // ARRAY_TYPE must be defined.
        if (empty(static::ARRAY_TYPE)) {
            throw new \RuntimeException(
                sprintf(
                    '%s::ARRAY_TYPE must be set to an allowable type.',
                    get_called_class()
                )
            );
        }

        // Validate that the ARRAY_TYPE is appropriate.
        try {
            $this->arrayType = $this->determineType(static::ARRAY_TYPE);
        } catch (\Collections\Exceptions\InvalidArgumentException $e) {
            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
        }

        // Validate that the input is an array or an object with an Traversable interface.
        if (!(is_array($input) || (is_object($input) && in_array(Traversable::class, class_implements($input))))) {
            throw new InvalidArgumentException('$input must be an array or an object that implements \Traversable.');
        }

        // Create an empty array.
        parent::__construct([], $flags, $iteratorClass);

        // Append each item so to validate it's type.
        foreach ($input as $key => $value) {
            $this[$key] = $value;
        }
    }

    /**
     * Adding a new value at the beginning of the collection
     *
     * @param mixed $value
     *
     * @return int Returns the new number of elements in the Array
     *
     * @throws InvalidArgumentException
     */
    public function unshift($value): int
    {
        try {
            $this->validateItem($value, $this->arrayType);
        } catch (\Collections\Exceptions\InvalidArgumentException $e) {
            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
        }

        return parent::unshift($value);
    }

    /**
     * Check the type and then store the value.
     *
     * @param mixed $offset The offset to store the value at or null to append the value.
     * @param mixed $value The value to store.
     *
     * @throws InvalidArgumentException
     */
    public function offsetSet($offset, $value)
    {
        try {
            $this->validateItem($value, $this->arrayType);
        } catch (\Collections\Exceptions\InvalidArgumentException $e) {
            throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
        }

        parent::offsetSet($offset, $value);
    }

    /**
     * Sort an array, taking into account objects being able to represent their sortable value.
     *
     * {@inheritdoc}
     */
    public function sort($sortFlags = SORT_REGULAR)
    {
        if (!in_array(SortableInterface::class, class_implements($this->arrayType))) {
            throw new \RuntimeException(
                sprintf(
                    "Cannot sort an array of '%s' as that class does not implement '%s'.",
                    $this->arrayType,
                    SortableInterface::class
                )
            );
        }
        // Get the data from
        $originalData = $this->getArrayCopy();
        $sortableData = array_map(
            function (SortableInterface $item) {
                return $item->getSortValue();
            },
            $originalData
        );

        $result = asort($sortableData, $sortFlags);

        $order = array_keys($sortableData);
        uksort(
            $originalData,
            function ($key1, $key2) use ($order) {
                return array_search($key1, $order) <=> array_search($key2, $order);
            }
        );

        $this->exchangeArray($originalData);

        return $result;
    }

    /**
     * {@inheritdoc}
     */
    public function filter(callable $callback, int $flag = 0)
    {
        if ($flag == ARRAY_FILTER_USE_KEY) {
            throw new InvalidArgumentException('Cannot filter solely by key. Use ARRAY_FILTER_USE_BOTH and amend your callback to receive $value and $key.');
        }

        return parent::filter($callback, $flag);
    }
}

An example use.

class PaymentChannelCollection extends AbstractTypedArray
{
    const ARRAY_TYPE = PaymentChannel::class;
}

You can now typehint with PaymentChannelCollection and be sure you've got a collection of PaymentChannels (for example).

Some of the code may call exceptions in our namespace. I think there's a type validator too from danielgsims/php-collections (we initially used those collections but had issues around the flexibility of them - they're good, just not for us - so maybe take a look at them anyway!).

Hatching answered 8/1, 2019 at 17:31 Comment(0)
H
9

I'm giving a generic answer about the type hinting of arrays in general.

I made a variation of the selected answer. The main difference is that the parameter is an array instead of many instances of the checked class.

/**
 * @param $_foos Foo[]
 */
function doFoo(array $_foos)
{return (function(Foo ...$_foos){

    // Do whatever you want with the $_foos array

})(...$_foos);}

It looks a bit fuzzy but it's pretty easy to understand. Instead of always unpacking the array at each call manually, the closure inside the function gets called with your array unpacked as the parameter.

function doFoo(array $_foos)
{
    return (function(Foo ...$_foos){ // Closure

    // Do whatever you want with the $_foos array

    })(...$_foos); //Main function's parameter $_foos unpacked
}

I find this pretty cool since you can use the function like any other language function having a ArrayOfType parameter. Plus, the error is handled the same way as the rest of PHP type hint errors. Furthermore, you are not confusing other programmers who will use your function and would have to unpack their array which always feels a bit hacky.

You do need a bit of experience in programming to understand how this works. If you need more than one parameter you can always add them in the 'use' section of the closure.

You can also use doc comments to expose the type hint.

/**
 * @param $_foos Foo[] <- An array of type Foo
 */

Here is an OO example:

class Foo{}

class NotFoo{}

class Bar{
    /**
     * @param $_foos Foo[]
     */
    public function doFoo(array $_foos, $_param2)
    {return (function(Foo ...$_foos) use($_param2){

        return $_param2;

    })(...$_foos);}
}

$myBar = new Bar();
$arrayOfFoo = array(new Foo(), new Foo(), new Foo());
$notArrayOfFoo = array(new Foo(), new NotFoo(), new Foo());

echo $myBar->doFoo($arrayOfFoo, 'Success');
// Success

echo $myBar->doFoo($notArrayOfFoo, 'Success');
// Uncaught TypeError: Argument 2 passed to Bar::{closure}() must be an instance of Foo, instance of NotFoo given...

Note: This also works with non-object types (int, string, etc.)

Hexateuch answered 23/1, 2019 at 18:11 Comment(0)
H
8

As arrays can contain mixed values this is not possible.

You have to use an objects / class for that purpose.

You could create a class that will manage its own list array (private/protected attribute) and deny adding other values as a workarround for this issue if this is really needed.

However no responsible programmer will ever break the intended pattern, especially not if you comment it correctly. It will be recognized in occuring errors in the program anyway.

The exaplanation:

For example you can create any array:

$myArray = array();

and add a number:

$myArray[] = 1;

a string:

$myArray[] = "abc123";

and an object

$myArray[] = new MyClass("some parameter", "and one more");

Also do not forget that you can have a simple array, a multi-dimensional stacked array and also associative arrays which can have mixed patterns aswell.

Its pretty hard till impossible to found a parser/nottation to make all that versions work with an expression that forces the format for an array I think.

It would be cool on the one side but on the other side of the medal you would loose some ability to mix data within an array which could be crucial to alot of existing code and the flexibility PHP has to offer.

Because of the mixed content which feature we do not want to miss in PHP 7 it is not possible to type-hint the exact contents of an array as you can put inside anything.

Halmahera answered 14/12, 2015 at 17:50 Comment(1)
The aim is precisely to enforce the type of all entries in the array. If I got a class Foo with a "bar" method, and my function calls the "bar" method on every element of the array, I want to type hint my argument as an "array of Foo"Showman
S
4

Adding on to what Steini has answered.

You could create a class ObjectNIterator that manages your ObjectN and implements an Iterator: http://php.net/manual/en/class.iterator.php

From methodN, call the classMethodM that gives back a populated ObjectNIterator then pass this data to a methodO that expects ObjectNIterator:

public function methodO(ObjectNIterator $objectNCollection)

Sopher answered 25/1, 2017 at 12:34 Comment(1)
Yes, I do exactly the same on PHP 5.x too, but there using the PHPDoc type hinting, which is used by the PhpStorm IDE (which shows all possible problems nicely). I have an object-relational mapper, which generates these typed iterator classes (arrays) from a declarative XML specification (among with the models), so I have no problem creating them quickly. It would be nice to have PHP transformed in the future into a strongly typed language.Ruffled
F
3

For now there is no way to define it on function signature for array of objects. But you can define it on function documentation. It won't generate PHP error/warning if you passing mixed value, but most IDE will give hints. Here is the example:

/**
 * @param int $age
 * @param User[] $users
 * @return User[]
 */
function findUserByAge(int $age, array $users) : array {
    $results = [];
    //
    //
    return $result;
}
Fuse answered 10/11, 2020 at 5:32 Comment(0)
A
1

A fairly simple approach is to create your own array type which works with PHP's built-in functions such as foreach, count, unset, indexing, etc. Here is an example:

class DataRowCollection implements \ArrayAccess, \Iterator, \Countable
{
    private $rows = array();
    private $idx = 0;

    public function __construct()
    {
    }

    // ArrayAccess interface

    // Used when adding or updating an array value
    public function offsetSet($offset, $value)
    {
        if ($offset === null)
        {
            $this->rows[] = $value;
        }
        else
        {
            $this->rows[$offset] = $value;
        }
    }

    // Used when isset() is called
    public function offsetExists($offset)
    {
        return isset($this->rows[$offset]);
    }

    // Used when unset() is called
    public function offsetUnset($offset)
    {
        unset($this->rows[$offset]);
    }

    // Used to retrieve a value using indexing
    public function offsetGet($offset)
    {
        return $this->rows[$offset];
    }

    // Iterator interface

    public function rewind()
    {
        $this->idx = 0;
    }

    public function valid()
    {
        return $this->idx < count($this->rows);
    }

    public function current()
    {
        return $this->rows[$this->idx];
    }

    public function key()
    {
        return $this->idx;
    }

    public function next()
    {
        $this->idx++;
    }

    // Countable interface

    public function count()
    {
        return count($this->rows);
    }
}

Usage example:

$data = new DataRowCollection(); // = array();
$data[] = new DataRow("person");
$data[] = new DataRow("animal");

It works just like a traditional array, but it is typed like you wanted it to be. Very simple and effective.

Appleton answered 16/11, 2019 at 18:38 Comment(2)
The point of the question was about enforcing the type of the array's elements.Magic
One could certainly achieve that. Simply extend offsetSet(..) with a runtime type check. Type hinting won't work since PHP needs the implementation to be identical to the interface function, but runtime checking will work. Perhaps something like this: if (get_class($value) !== "DataRow") { throw new Exception("Instance of DataRow expected"); }Appleton

© 2022 - 2024 — McMap. All rights reserved.