[Sequence] Add "terminal" methods: element access - #29
Conversation
First of four PRs bringing the terminal vocabulary of decision 5 to
Sequence: first/firstOrNull, last/lastOrNull, single/singleOrNull,
elementAt/elementAtOrNull, find and expect. Until now a sequence could
only be ended by toList/toSet/toArray, so sequenceOf($rows)->filter(...)
->first() did not exist.
Supersedes the "terminals duplicated, not refactored" decision: bodies
identical on both sides now live in a shared IterableTerminalsLogic trait
used by CollectionLogic and SequenceLogic alike. A generic trait whose
PHPDoc is {@inheritdoc}, resolving against whichever interface the
consumer implements, is clean at PHPStan level 9 - that was the open risk
of sharing rather than duplicating.
A message naming its own subject used to be a second reason to duplicate,
since a sequence must not be told a "Collection" is empty. It no longer
is: NoSuchElementException derives that noun from the subject it is
handed, so single() is shared too. That unblocks min/max/minOf/maxOf/avg
for the aggregation PR, which are duplicated for the same reason alone.
What still cannot be shared is structural: the eager side answers
first/last from its store in O(1) while a sequence has to pull, so
first() returns inside the foreach to keep it to one element and last()
drains, the last element being knowable only at the end.
elementAt/elementAtOrNull are new vocabulary, on Sequence only. The
library's index accessor is ListInterface::get(), O(1) random access a
sequence cannot offer; Kotlin draws the same line between List.get() and
Sequence.elementAt(), and the separate name is what carries the O(n) cost
to the reader. They are deliberately not in the shared trait either:
Collection does not declare them, so putting them there would add an
undeclared public method to every set and map view.
Terminals carry #[NoDiscard] unlike their Collection counterparts:
discarding one silently burns the only pass a sequence may have.
The eager messages had no test coverage, so nothing would have caught the
shared factory changing them; CollectionSingle and CollectionAggregate now
assert all seven.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
| /** | ||
| * @param Collection<mixed>|Sequence<mixed> $subject | ||
| */ | ||
| public static function emptySubject(Collection|Sequence $subject): self |
There was a problem hiding this comment.
I saw that this lib does not use named constructor for exceptions, but I'm a huge fan of them (I'm not the only one 😁), and this did simplify the problem of sharing code here
There was a problem hiding this comment.
I'm not a huge fan though 😄 but I've also started using it in another project im working on for better testability.. the thing I hate is when the stacktrace is displayed (in some debugger tool like Tracy) - the 1st trace that's unpacked and shows 5-10 lines of code, is showing the exception code, and not where it was thrown.. because it's where the class was created
| * the foreach is what keeps it to a single element. | ||
| */ | ||
| #[NoDiscard] | ||
| public function first() |
There was a problem hiding this comment.
Collection::first() does not have any return type in its prototype, so this mimics what is done there, but maybe we could add : mixed everywhere?
There was a problem hiding this comment.
i think I made it on purpose like this, because mixed allows also null and phpstan (or the IDE) was complaining
There was a problem hiding this comment.
but nothing prevents to do :
$l = listOf([null, null]);
\PHPStan\dumpType($l); // Dumped type: Noctud\Collection\List\ImmutableList<null>
var_dump($l->first()); // prints "null"
\PHPStan\dumpType($l->first()); // Dumped type: null😁
There was a problem hiding this comment.
that's correct though, because null is now part of E, but if E does not include nulls, mixed is too loose
| * | ||
| * @template E | ||
| */ | ||
| trait IterableTerminalsLogic |
There was a problem hiding this comment.
I've noticed the methods don't have #[NoDiscard], because on Collection those methods just don't have the attribute, and that's in fact correct.. sorry if I told you something else in previous PRs, I remember deciding to add NoDiscard just on methods that return same type and not on element access, e.g. you could ask "does this hand back a new container while leaving original unchanged?", which applies to transformation, ordering and conversion methods.. it makes sense because if someone does $collection->filter(...) on MutableCollection, they might expect that it will be filtered internally, and then it warns them.. I didn't want to be too strict with that NoDiscard, so we should align Sequence with that design..
There was a problem hiding this comment.
probably we could add it later versions when people are more used to it, I'm just litle hesitant, because it's a new attribute and I haven't seen it used almost anywhere.. and there might be a use case I'm not aware of, like you want to do a call just to materialize collection, or load lazy collection (execute callback that pulls data from db) etc.
| * the index is only known to be past the end once the source runs out. | ||
| */ | ||
| #[NoDiscard] | ||
| public function elementAt(int $index) |
There was a problem hiding this comment.
if negative integer is passed, we drain the sequence, maybe we should have a condition that if it's negative, just throw?
|
|
||
| /** {@inheritDoc} */ | ||
| #[NoDiscard] | ||
| public function elementAtOrNull(int $index): mixed |
There was a problem hiding this comment.
same thing here with negative integers
| { | ||
| try { | ||
| return $this->single(); | ||
| } catch (NoSuchElementException) { |
There was a problem hiding this comment.
this swallows real NoSuchElementException that could be thrown in some map() operation in the chain, even from like another object, maybe this should be implemented directly as single() without that try-catch
| * Consumers declare the contract, so the PHPDoc here is {@inheritDoc}: it resolves against | ||
| * Collection<E> or Sequence<E> depending on who uses the trait. | ||
| * | ||
| * @template E |
There was a problem hiding this comment.
shouldn't there also be @mixin so it says it can be used only on Collection and Sequence? or now that i think about it.. maybe also @internal, since this trait is only part of our traits, but I'm not sure about that
| * @throws InvalidSequenceSourceException If a Closure source returns a non-iterable | ||
| */ | ||
| #[NoDiscard] | ||
| public function elementAt(int $index); |
There was a problem hiding this comment.
I wonder if we should add this also on Collection, seems like useful method, when someone has a Set and they want 3. item for some reason, they have now to convert to list and use random access, or do the loop themselves and keep track of counter.. even though $set->find(fn ($v, $i) => $i === 3) is also an option, but not obvious.. and ListLogic would delegate it to random access?
Related to #23.
First of four PRs bringing the terminal vocabulary to
Sequence— element access here, then querying, aggregation, andforEach/toMap.Until now a sequence could only be ended by
toList()/toSet()/toArray(), sosequenceOf($rows)->filter(…)->first()did not exist: you had to materialize a list first, which defeats the point of the type. This addsfirst/firstOrNull,last/lastOrNull,single/singleOrNull,elementAt/elementAtOrNull,findandexpect.Terminals are shared, not duplicated
The original plan was to copy the
foreach ($this …)bodies fromCollectionLogicintoSequenceLogicand keep the footprint on existing files near zero. With ~25 shareable bodies in total across the four PRs, that would have meant two copies drifting apart, so this goes the other way: bodies identical on both sides live in a newIterableTerminalsLogic, used byCollectionLogicandSequenceLogicalike.The open risk was PHPStan. The trait carries
@template E, is consumed through/** @use IterableTerminalsLogic<E> */, and its PHPDoc is reduced to{@inheritDoc}so that each method resolves against whichever interface the consumer implements —Collection<E>orSequence<E>. That turns out to be clean at level 9 with no new ignores, which is what made sharing viable.The exception derives the subject's name
A message naming its own subject used to be a second reason to duplicate: a sequence must not be told a "Collection" is empty. Rather than keep two copies of
single()for two nouns,NoSuchElementExceptionnow derives the noun from the subject it is handed:One body, the right noun. The
matchlives in an@internal NamesItsSubjecttrait shared by the exception classes; its parameter typeCollection|Sequencemakes it exhaustive, so thedefaultarm is the Collection case rather than a catch-all.This also unblocks the aggregation PR:
min,max,minOf,maxOfandavgare duplicated today for that reason and that reason only.CollectionSingleandCollectionAggregatenow assert the eager messages. They asserted only the exception class before, so nothing would have caught the shared factory silently changing'Collection is empty'— without those seven assertions, a green suite would not have proven the refactor preserved the eager side.What deliberately stays per side
first/lastare not shared, and that is structural rather than incidental: the eager side answers them from its store in O(1) (array_key_first/array_key_last), while a sequence has to pull. Sofirst()returns inside theforeachto keep it to exactly one element, andlast()drains — the last element is only knowable once the source runs out. Same reasoning will apply tocontainsandcountin the querying PR.elementAt/elementAtOrNullThese are new vocabulary, and on
Sequenceonly. The library's index accessor isListInterface::get(), which is O(1) random access a sequence cannot offer. Kotlin draws the same line betweenList.get()andSequence.elementAt(), and the distinct name is what carries the O(n) cost to the reader —get(int)on a sequence would suggest a direct access it cannot do.They are also deliberately not in the shared trait:
Collectiondoes not declare them, so putting them there would add an undeclared public method to every set and map view. Happy to expose them onCollectiontoo if that is preferred, but that is an API addition rather than sharing.Note this makes three places where
Sequencediverges fromCollection:onEach(already merged, itsCollectioncounterpart being a separate follow-up), plus these two.#[NoDiscard]on every terminalStricter than
Collection, wherefirst(),count()andsum()carry no attribute — deliberately: discarding a terminal on a sequence silently burns the only pass it may have, which is worse than the same mistake on a collection.forEach(): voidwill be the one exception, in the fourth PR.Other details worth flagging
nullis indistinguishable from an empty one throughlastOrNull()— the same ambiguityCollection::lastOrNull()has, kept on purpose.first()pulls exactly one element,single()at most two,find()stops at the match,elementAt()stops at the position,last()drains.find/elementAttests pin after afilterreindexes.Verification
composer qagreen — PHPStan level 9 over 246 files with no new ignores, phpcs clean, 6335 tests.php bin/generate-all.phpleaves the generated narrowing files untouched.