Principles¶
This section describes a few elements which are at the core of Collection
and guides on how to get the most out of the package.
Immutability¶
Most operations return a new collection object rather than modifying the original one.
Immutability usually makes our code:
simpler to use, reason about, and test
easier to debug
more robust and consistent
A few notable exceptions to this are methods that return scalar values like
count
; read more about them in the operations background
section.
Laziness¶
While PHP’s default evaluation is call-by-value, Collection
allows you to
mimic a call-by-name evaluation strategy by evaluating items only when they
are needed.
Therefore, Collection
is “lazy” by default. Lazy evaluation is an
evaluation strategy which delays the evaluation of an expression until its value
is really needed.
Collection operations are executed on the input stream only when iterating over
it, or when using very specific operations like all
or squash
.
Behaviour¶
Collection
leverages PHP Generators
and Closures
to allow working
with large data sets while using as little memory as possible. Thanks to lazy
evaluation, we can even deal with potentially infinite data sets or streams of
data - see the advanced usage section for more examples.
$even = static fn(int $item): bool => $item % 2 === 0;
$square = static fn(int $item): int => $item * $item;
$collection = Collection::fromIterable(range(0, 5))
->filter($even)
->map($square); // $collection is still unchanged up to this point
// We can use `all` to get the results as an array
var_dump($collection->all()); // [0, 4, 16]
// Even better, we can iterate over the collection directly
foreach ($collection as $item) {
var_dump($item); // will print 0, 4, 16
}
Internals¶
The Collection
object is assisted by a couple of powerful components:
ClosureIterator - allows iterating over the collection object by creating a new PHP Generator each time iteration is started.
AbstractOperation and the Operation Interface - provide the blueprint
for collection operations, which are pure functions defined as final classes
with the invoke PHP magic method. Operations return a Closure
when
called, which itself returns an Iterator
; they can thus be used inside
ClosureIterator
to create new generators when needed.
Side-Effects¶
Collection
is a helper for making transformations to input data sets.
Even though we can technically trigger side-effects in some operations
through a custom Closure
, it’s better to avoid this type of usage and
instead use the operations for their transformative effects
(use the return values).
Exception handling is one scenario where we might find ourselves wanting
Collection
to behave eagerly. If we want an exception to be thrown and
handled in a specific function, during an operation, rather than when the
collection is later iterated on, we can take advantage of the
squash operation.
Testing¶
Working with lazy evaluation can impact how we test our code. Depending on the testing framework used, we have a few options at our disposal when it comes to comparing collections objects returned by a function.
Collection
already provides two operations which can be used for comparison:
Equals - allows usage of the assertObjectEquals assertion in PHPUnit
Same - allows customizing precisely how elements are compared using a callback
Note that these operations will traverse both collections as part of the
comparison. As such, any side effects triggered in our source code will be
triggered during this comparison. When using equals
in particular, we might
find it useful to apply squash
to the resulting collection object before the
comparison if our test needs to assert how many times a side effect is
performed.
In addition to these, in PHPUnit we can use the assertIdenticalIterable assertion to assert how our final collection object will iterate.
The last option is to transform the collection object into an array with the all operation and use any assertion that we would normally use for arrays.
Usability¶
Collection
and the Operations
are designed with usability and
flexibility in mind. A few key elements that contribute to this are the usage of
variadic parameters, custom callbacks, and the fact that operations can be
used both as collection object methods or completely separately.
Variadic Parameters¶
Variadic parameters are used wherever possible in operations, allowing us to more succinctly apply multiple transformations or predicates. They will always be evaluated by the operation as a logical OR.
For example, the contains operation allows us to easily check whether one or more values are contained in the collection:
<?php
declare(strict_types=1);
namespace App;
use loophp\collection\Collection;
include __DIR__ . '/../../../../vendor/autoload.php';
// Does it contains the letter 'd' ?
$result = Collection::fromIterable(range('a', 'c'))
->contains('d'); // false
// Does it contains the letter 'a' or 'z' ?
$result = Collection::fromIterable(range('a', 'c'))
->contains('a', 'z'); // true
// Does it contains the letter 'd' ?
$result = Collection::fromIterable(['a' => 'b', 'c' => 'd'])
->contains('d'); // true
If we want to instead achieve a logical AND behaviour, we can make multiple calls to the same operation. The following example using the filter operation illustrates this:
<?php
declare(strict_types=1);
namespace App;
use loophp\collection\Collection;
include __DIR__ . '/../../../../vendor/autoload.php';
$divisibleBy2 = static fn ($value): bool => 0 === $value % 2;
$divisibleBy3 = static fn ($value): bool => 0 === $value % 3;
// Filter values divisible by 3.
$collection = Collection::fromIterable(range(1, 10))
->filter($divisibleBy3); // [3, 6, 9]
// Filter values divisible by 2 or 3.
$collection = Collection::fromIterable(range(1, 10))
->filter($divisibleBy2, $divisibleBy3); // [2, 3, 4, 6, 8, 9, 10]
// Filter values divisible by 2 and 3.
$collection = Collection::fromIterable(range(1, 10))
->filter($divisibleBy2)
->filter($divisibleBy3); // [6]
Custom Callbacks¶
Many operations allow us to customize their behaviour through custom callbacks. This gives us the power to achieve what we need with the operation if the default behaviour is not the best fit for our use case.
For example, by default the same operation will compare collection
elements using strict equality (===
). However, when dealing with objects we
might want a different behaviour:
$a = (object) ['id' => 'a'];
$a2 = (object) ['id' => 'a'];
$comparator = static fn (stdClass $left) => static fn (stdClass $right): bool => $left->id === $right->id;
Collection::fromIterable([$a])
->same(Collection::fromIterable([$a2]), $comparator); // true
Independent Operations¶
Operations are pure functions that can be used to transform an iterator, either
directly or through the Collection
object.
For example, the filter operation can be used on another
iterator, independently of the Collection
object. Because all operations
return an iterator at the end, we can use iterator_to_array to convert this
back to a normal array when needed.
<?php
declare(strict_types=1);
namespace App;
use loophp\collection\Collection;
use loophp\collection\Operation\Filter;
include __DIR__ . '/../../../../vendor/autoload.php';
$input = [1, 2, 3, 4];
$even = static fn (int $value): bool => $value % 2 === 0;
// Standalone usage
$filtered = Filter::of()($even)($input);
print_r(iterator_to_array($filtered)); // [2, 4]
// Usage via Collection object
$filtered = Collection::fromIterable($input)->filter($even);
print_r($filtered->all()); // [2, 4]