Every time I see this
$users = [new User(), new User()];
I see a lost opportunity to use Iterator.
Why Iterators?
Collections are an awesome way to organize your previously no-named array. There is a couple of reasons why you should use iterators. One of reason stays for behavior, you can specify exact behavior on standard calls such as next, current, valid etc. Other reason could be that you want to ensure that collection contains an only specific type of an object.
Understand a suffer from using an array of unknown value types.
Very common in the PHP world arrays are used to store all kind of data, in many dimensions in many nested forms. Arrays introduced infinite flexibility to the developer, but because of that, they become very evil.
Example:
- Your function (getUsers) returns an array of User objects.
- Another function (setUsersToActiveState) using getUsers output array and set all users active status to true.
- setUsersToActiveState loop through the array and expect to call a specific method on array item. For example, the method name is getActiveStatus.
- If given array is an array of desired objects which have a callable method getActiveStatus, all fine. But if not exception will be thrown.
- How we can ensure that given array is always an array of objects of a specific type?
public function getUsers(): array
{
/**
here happen something which gets users from database
....
**/
return $userArray;
}
public function setUsersToActiveState()
{
$users = $this->getUsers();
/** @var User $param */
foreach ($users as $user) {
if(!$user->getActiveStatus()) {
$user->setActiveStatus(true);
}
}
}
There immediately two problems occurred.
- One is the problem of type. Our IDE doesn't know what's inside array of $users, so because of that IDE can't suggest us how to use $user element. (I put this comment block /** @var User $param */ above foreach, it works for phpStorm and I guess for some other IDEs)
- Your colleagues! How they possibly know what's inside array if there is no any hint.
- Bonus problem, getUsers can return literally any array and there won't be warning in the system.
Solution
// Create a collection which accepts only Users
class UsersCollection implements \IteratorAggregate
{
/** @var array */
private $users = [];
public function getIterator() : UserIterator
{
return new UserIterator($this);
}
public function getUser($position)
{
if (isset($this->users[$position])) {
return $this->users[$position];
}
return null;
}
public function count() : int
{
return count($this->users);
}
public function addUser(User $users)
{
$this->users[] = $users;
}
}
// Create an Iterator for User
class UserIterator implements \Iterator
{
/** @var int */
private $position = 0;
/** @var UsersCollection */
private $userCollection;
public function __construct(UsersCollection $userCollection)
{
$this->userCollection = $userCollection;
}
public function current() : User
{
return $this->userCollection->getUser($this->position);
}
public function next()
{
$this->position++;
}
public function key() : int
{
return $this->position;
}
public function valid() : bool
{
return !is_null($this->userCollection->getUser($this->position));
}
public function rewind()
{
$this->position = 0;
}
}
Tests
Off course there is the tests to ensure that our Collection and Iterator works like a charm. For this example I using syntax for PHPUnit framework.
class UsersCollectionTest extends TestCase
{
/**
* @covers UsersCollection
*/
public function testUsersCollectionShouldReturnNullForNotExistingUserPosition()
{
$usersCollection = new UsersCollection();
$this->assertEquals(null, $usersCollection->getUser(1));
}
/**
* @covers UsersCollection
*/
public function testEmptyUsersCollection()
{
$usersCollection = new UsersCollection();
$this->assertEquals(new UserIterator($usersCollection), $usersCollection->getIterator());
$this->assertEquals(0, $usersCollection->count());
}
/**
* @covers UsersCollection
*/
public function testUsersCollectionWithUserElements()
{
$usersCollection = new UsersCollection();
$usersCollection->addUser($this->getUserMock());
$usersCollection->addUser($this->getUserMock());
$this->assertEquals(new UserIterator($usersCollection), $usersCollection->getIterator());
$this->assertEquals($this->getUserMock(), $usersCollection->getUser(1));
$this->assertEquals(2, $usersCollection->count());
}
private function getUserMock()
{
// returns the mock of User class
}
}
class UserIteratorTest extends MockClass
{
/**
* @covers UserIterator
*/
public function testCurrent()
{
$iterator = $this->getIterator();
$current = $iterator->current();
$this->assertEquals($this->getUserMock(), $current);
}
/**
* @covers UserIterator
*/
public function testNext()
{
$iterator = $this->getIterator();
$iterator->next();
$this->assertEquals(1, $iterator->key());
}
/**
* @covers UserIterator
*/
public function testKey()
{
$iterator = $this->getIterator();
$iterator->next();
$iterator->next();
$this->assertEquals(2, $iterator->key());
}
/**
* @covers UserIterator
*/
public function testValidIfItemInvalid()
{
$iterator = $this->getIterator();
$iterator->next();
$iterator->next();
$iterator->next();
$this->assertEquals(false, $iterator->valid());
}
/**
* @covers UserIterator
*/
public function testValidIfItemIsValid()
{
$iterator = $this->getIterator();
$iterator->next();
$this->assertEquals(true, $iterator->valid());
}
/**
* @covers UserIterator
*/
public function testRewind()
{
$iterator = $this->getIterator();
$iterator->rewind();
$this->assertEquals(0, $iterator->key());
}
private function getIterator() : UserIterator
{
return new UserIterator($this->getCollection());
}
private function getCollection() : UsersCollection
{
$userItems[] = $this->getUserMock();
$userItems[] = $this->getUserMock();
$usersCollection = new UsersCollection();
foreach ($userItems as $user) {
$usersCollection->addUser($user);
}
return $usersCollection;
}
private function getUserMock()
{
// returns the mock of User class
}
}
Usage
public function getUsers(): UsersCollection
{
$userCollection = new UsersCollection();
/**
here happen something which gets users from database
....
**/
foreach ($whatIGetFromDatabase as $user) {
$userCollection->addUser($user);
}
return $userCollection;
}
public fucntion setUsersToActiveState()
{
$users = $this->getUsers();
foreach ($users as $user) {
if(!$user->getActiveStatus()) {
$user->setActiveStatus(true);
}
}
}
As you can see setUsersToActiveState remains the same, we only do not need to specify for our IDE or collagues what type $users variable is.
Extending functionalities
Believe or not you can reuse this two objects and just change names of variables to fit most of the needs. But if you want any more complex functionality, than feel free to add it in iterator or collection.
Example 1
For example, let's say that userCollection accepts only users with age more than 18. Implementation will happen in UsersCollection class in the method addUser.
public function addUser(User $users)
{
if ($user->getAge() > 18) {
$this->users[] = $users;
}
}
Example 2
You need to add bulk users. Then you can expand your userCollection with additional method addUsers and it might look like this.
public function addUsers(array $users)
{
foreach($users as $user) {
$this->addUser(User $users);
}
}
Note
I found this great article which answers, why is generally a bad idea to return an array, and I can't agree more with @aleksikauppila on this topic
Top comments (4)
How would you implement a solution where the collection indexes didn't start at 0? In the project I'm working on, there are several places where traversal of a large array takes too long, so I specify the index with a known number. Then I can do a
isset($arr[$obj->id])
to find, retrieve, delete, or overwrite the object at that position. I tried to implement this adapting your code but it creates a problem with the$position
variable in the iterator. In PHP 7.3 they added aarray_key_first()
method to more easily get the first key, but I'm operating in 7.2. I've tried a foreach hack, but it creates an internal loop in the iterator.Hei Ryan,
I found your question interesting, so I decided to answer in a longer form with a whole blog post. Please let me know did I answer your question and let me know if I could make it better.
Cheers!
Answer to the Iterator question
Damnjan Jovanovic
there is a typo in: public fucntion setUsersToActiveState
fucntion->function :D
I fixed the mistake, thank you so much for pointing out :) You have a sharp eye