PHP 7中的类型提示 - 对象数组

106

也许我错过了什么,但是否有选项可以定义函数应该具有参数或返回例如User对象数组?

考虑以下代码:

<?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";
}

在PHP7中是否有选项可以告诉函数findUserByAge应返回用户数组?我本应该期望当类型提示被添加时,这应该是可能的,但我没有找到有关对象数组类型提示的任何信息,因此它可能未包含在PHP 7中。如果没有包含,您有任何线索说明为什么在添加类型提示时未包含它吗?


11
仅仅是惯例,例如在文档块中使用“@return User[]”。 - Gordon
7个回答

145

这不包括在内。

如果没有包含,你有任何线索知道为什么在添加类型提示时它没有被包括进去吗?

使用当前的数组实现,它需要在运行时检查所有数组元素,因为数组本身不包含类型信息。

它实际上已经在 PHP 5.6 中被提出,但被拒绝:RFC "arrayof" - 有趣的是,不是因为性能问题,而是因为没有就如何准确实现达成一致。还有反对意见认为,如果没有标量类型提示,它就是不完整的。如果你对整个讨论感兴趣,请阅读邮件列表档案

在我看来,数组类型提示与类型化数组一起提供了最大的好处,我希望看到它们得到实现。

所以也许现在是时候提出新的 RFC 并重新开放这个讨论了。


部分解决方法:

你可以对可变参数进行类型提示,从而编写签名为

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

使用方法:

findUserByAge(15, ...$userInput);

在这个调用中,参数$userInput将会被“解包”成单个变量,并且在方法内部被“打包”回到一个数组$users中。每个项都要验证其类型为User。如果$userInput是一个迭代器,它将会被转换成一个数组。
不幸的是,对于返回类型没有类似的解决方法,你只能在最后一个参数中使用它。

3
不,而且在7.2版本中仍然看不到,参考https://wiki.php.net/rfc#php_next_72。 - Fabian Schmengler
44
对于 IDE,您可以使用 phpDoc @return User[] - Fabian Schmengler
4
有人在使用可变参数并以显示方式呈现时测量过性能缺陷吗? - metamaker
3
可变参数会导致速度明显变慢,并且在处理大型集合时可能会极大地影响内存使用。这是因为集合必须被拆分成单独的参数,然后再重新打包成一个变量数组。显而易见,集合中的对象越多,影响就越大。即使在遍历整个集合以验证类型之前,它仍然较慢。 - Will B.
1
那个 RFC 已经相当老了,是在 2014 年提出的。通过阅读邮件列表,我们可以看到两个最大的问题是如何处理 NULL 值和缺少标量类型提示。这两个问题已经在 PHP 7/8 中得到解决。我们现在有了可为空的类型提示,所以 ?User[] 可以允许 NULL 值,而 User[] 则不行。标量类型提示已经存在,所以 int[] 也可以工作。我想知道为什么 PHP 开发人员没有根据当前 PHP 特性状态重新考虑那个 RFC 提案。 - Smoky McPot
显示剩余3条评论

12

在我们的代码库中,有一个集合的概念。它们基于一个叫做 TypedArray 的类,这个类基于 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);
    }
}

/**
 * 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);
    }
}

一个使用示例。

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

现在你可以使用PaymentChannelCollection进行类型提示,并确保你拥有一组PaymentChannels(例如)。

我们的某些代码可能会调用我们命名空间中的异常。我认为还有来自danielgsims/php-collections的类型验证器(最初我们使用了这些集合,但对它们的灵活性存在问题-它们很好,只是不适合我们-所以也许还是看看它们吧! )。


9

我将提供一个通用的关于数组类型提示的答案。

我做了选定答案的变体,主要区别是参数是一个数组而不是多个已检查类的实例。

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

    // Do whatever you want with the $_foos array

})(...$_foos);}

这段话的意思是虽然看起来有点模糊,但理解起来很容易。函数内部的闭包会自动将数组作为参数进行拆包,而不需要每次手动拆包。

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

    // Do whatever you want with the $_foos array

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

我觉得这很酷,因为你可以像使用其他语言函数一样使用带有ArrayOfType参数的函数。此外,错误处理方式与PHP类型提示错误的其余部分相同。此外,您不会让其他程序员混淆,他们将使用您的函数,并且必须解压缩数组,这总是感觉有点hacky。
您需要一些编程经验才能理解它的工作原理。如果您需要多个参数,则始终可以在闭包的“use”部分中添加它们。
您还可以使用文档注释来公开类型提示。
/**
 * @param $_foos Foo[] <- An array of type Foo
 */

以下是一个面向对象的示例:

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...

注意:这也适用于非对象类型(int,string等)


8
由于数组可以包含混合值,所以这是不可能的。
你必须使用对象/类来实现此目的。
如果确实需要,你可以创建一个类来管理其自己的列表数组(私有/受保护属性),并拒绝添加其他值作为解决此问题的方法。
然而,没有负责任的程序员会违反既定模式,特别是如果你正确注释了代码。它将在程序中出现错误时被发现。
举个例子,你可以创建任何数组:
$myArray = array();

并添加一个数字:

$myArray[] = 1;

一个字符串:

$myArray[] = "abc123";

和一个对象

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

此外,请不要忘记您可以拥有一个简单的数组,一个多维堆叠的数组,以及可以具有混合模式的关联数组。

我认为很难找到一个解析器/符号来使所有这些版本都能使用强制数组格式的表达式工作。

一方面很酷,但另一方面,您会失去一些将数据混合在数组中的能力,这可能对许多现有代码和PHP提供的灵活性至关重要。

由于混合内容是我们不想在PHP 7中丢失的功能,因此无法对数组的确切内容进行类型提示,因为您可以将任何东西放入其中。


2
目的正是强制数组中所有条目的类型。如果我有一个带有“bar”方法的类Foo,并且我的函数在数组的每个元素上调用“bar”方法,我想要将参数类型提示为“Foo数组”。 - Pierre-Olivier Vares

4

补充Steini的回答。

您可以创建一个名为ObjectNIterator的类,该类管理您的ObjectN并实现迭代器:http://php.net/manual/zh/class.iterator.php

在methodN中,调用classMethodM方法返回已填充的ObjectNIterator,然后将此数据传递给期望ObjectNIterator的methodO方法:

public function methodO(ObjectNIterator $objectNCollection)


是的,我在 PHP 5.x 上也完全一样,但是使用了 PHPDoc 类型提示,在 PhpStorm IDE 中使用(它可以很好地显示所有可能的问题)。我有一个对象关系映射器,从声明性 XML 规范中生成这些带类型的迭代器类(数组),因此我可以快速地创建它们,毫无问题。未来将 PHP 转化为强类型语言会很不错。 - Crouching Kitten

3

目前在函数签名中没有办法定义对象数组类型。但你可以在函数文档中定义它。如果你传递混合值,它不会生成PHP错误/警告,但大多数IDE将给出提示。这是一个例子:

/**
 * @param int $age
 * @param User[] $users
 * @return User[]
 */
function findUserByAge(int $age, array $users) : array {
    $results = [];
    //
    //
    return $result;
}

1
一个相对简单的方法是创建自己的数组类型,该类型可以与PHP内置函数一起使用,例如foreach、count、unset、索引等。以下是一个示例:
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);
    }
}

使用示例:

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

它的工作方式就像传统数组一样,但是它是按照您想要的类型进行编写的。非常简单而有效。

1
问题的重点在于强制数组元素的类型。 - miken32
可以通过在offsetSet(..)中添加运行时类型检查来实现。简单地扩展它即可。由于PHP需要实现与接口函数完全相同,因此类型提示无法起作用,但运行时检查可以起作用。可能是这样的:if (get_class($value) !== "DataRow") { throw new Exception("期望 DataRow 实例"); } - Jimmy Thomsen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接