Skip to content

[Sequence] Add "terminal" methods: element access - #29

Open
nikophil wants to merge 1 commit into
noctud:0.2.xfrom
nikophil:feature/sequence-terminals
Open

[Sequence] Add "terminal" methods: element access#29
nikophil wants to merge 1 commit into
noctud:0.2.xfrom
nikophil:feature/sequence-terminals

Conversation

@nikophil

@nikophil nikophil commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Related to #23.

First of four PRs bringing the terminal vocabulary to Sequence — element access here, then querying, aggregation, and forEach/toMap.

Until now a sequence could only be ended by toList()/toSet()/toArray(), so sequenceOf($rows)->filter(…)->first() did not exist: you had to materialize a list first, which defeats the point of the type. This adds first/firstOrNull, last/lastOrNull, single/singleOrNull, elementAt/elementAtOrNull, find and expect.

Terminals are shared, not duplicated

The original plan was to copy the foreach ($this …) bodies from CollectionLogic into SequenceLogic and 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 new IterableTerminalsLogic, used by CollectionLogic and SequenceLogic alike.

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> or Sequence<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, NoSuchElementException now derives the noun from the subject it is handed:

throw NoSuchElementException::emptySubject($this);
listOf([])->single()           Collection is empty
setOf([])->single()            Collection is empty
mapOf([])->values->single()    Collection is empty
sequenceOf([])->single()       Sequence is empty

One body, the right noun. The match lives in an @internal NamesItsSubject trait shared by the exception classes; its parameter type Collection|Sequence makes it exhaustive, so the default arm is the Collection case rather than a catch-all.

This also unblocks the aggregation PR: min, max, minOf, maxOf and avg are duplicated today for that reason and that reason only.

CollectionSingle and CollectionAggregate now 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/last are 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. So first() returns inside the foreach to keep it to exactly one element, and last() drains — the last element is only knowable once the source runs out. Same reasoning will apply to contains and count in the querying PR.

elementAt / elementAtOrNull

These are new vocabulary, and on Sequence only. The library's index accessor is ListInterface::get(), which is O(1) random access a sequence cannot offer. Kotlin draws the same line between List.get() and Sequence.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: Collection does not declare them, so putting them there would add an undeclared public method to every set and map view. Happy to expose them on Collection too if that is preferred, but that is an API addition rather than sharing.

Note this makes three places where Sequence diverges from Collection: onEach (already merged, its Collection counterpart being a separate follow-up), plus these two.

#[NoDiscard] on every terminal

Stricter than Collection, where first(), count() and sum() 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(): void will be the one exception, in the fourth PR.

Other details worth flagging

  • Empty-sequence behaviour is pinned per terminal, including that a sequence ending on null is indistinguishable from an empty one through lastOrNull() — the same ambiguity Collection::lastOrNull() has, kept on purpose.
  • Laziness is asserted, not assumed: first() pulls exactly one element, single() at most two, find() stops at the match, elementAt() stops at the position, last() drains.
  • Every terminal consumes exactly one pass, so a second call on a one-shot source throws — a partial pass counts as consumed.
  • Index arguments passed to callbacks are the positions of that stage, not of the source, which the find/elementAt tests pin after a filter reindexes.

Verification

composer qa green — PHPStan level 9 over 246 files with no new ignores, phpcs clean, 6335 tests. php bin/generate-all.php leaves the generated narrowing files untouched.

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

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

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

@nikophil nikophil Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@delacry delacry Aug 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think I made it on purpose like this, because mixed allows also null and phpstan (or the IDE) was complaining

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

😁

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's correct though, because null is now part of E, but if E does not include nulls, mixed is too loose

@nikophil
nikophil marked this pull request as ready for review August 2, 2026 09:25
*
* @template E
*/
trait IterableTerminalsLogic

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same thing here with negative integers

{
try {
return $this->single();
} catch (NoSuchElementException) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/Sequence/Sequence.php
* @throws InvalidSequenceSourceException If a Closure source returns a non-iterable
*/
#[NoDiscard]
public function elementAt(int $index);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants