Skip to content

[Collection] Add elementAt() and elementAtOrNull() - #46

Merged
delacry merged 3 commits into
noctud:0.2.xfrom
nikophil:feature/collection-element-at
Sep 30, 2026
Merged

delacry merged 3 commits into
noctud:0.2.xfrom
nikophil:feature/collection-element-at

Conversation

@nikophil

Copy link
Copy Markdown
Contributor

Answers #29 (comment) — getting the 3rd
item of a Set currently means toList() first, or find(fn ($v, $i) => $i === 3), which
nobody guesses.

$set->elementAt(2);          // walks to the position
$list->elementAt(2);         // random access, same call
$map->values->elementAt(2);  // works on the views too

Two implementations, as suggested in that thread:

  • CollectionLogic::elementAt() walks the elements and counts — the only thing a Set can do.
  • ListLogic overrides both with $this->store->get(), so a list keeps its O(1) access.

List, Set, MapKeySet, MapEntrySet and MapValueCollection all mount CollectionLogic,
so one trait of tests (CollectionFirstLast) covers the five of them, both code paths included.

The generic walk has no negative-index guard, unlike the Sequence one: positions start at
0, so a negative index never matches and falls through to the same out-of-bounds error as an
index past the end. Less code, same behaviour.

One behaviour change: elementAtOrNull(-1)

Sequence::elementAtOrNull(-1) currently throws. That makes it the odd one out:

call before after
List::getOrNull(-1) null null (untouched)
Sequence::elementAtOrNull(-1) throws null
Collection::elementAtOrNull(-1) — null
elementAt(-1), every type throws throws (untouched)

Kotlin's elementAtOrNull opens with if (index < 0) return null, and getOrNull() has
returned null for a negative index since 0.1.x. The rejection made sense on elementAt() — #29
documents it deliberately — but it looks like it was carried over to the OrNull variant
without that being the intent, and asSequence() makes the divergence easy to walk into. So
the rule is now one line: the throwing variant rejects a negative index, the OrNull variant
answers "nothing there"
. 0.2.x is unreleased, so this costs nothing.

The @param non-negative-int on Sequence::elementAtOrNull() goes with it — the runtime now
accepts what the signature was forbidding.

Codegen is neutral: elementAt() returns E, so there is nothing for the narrowing passes to
rewrite.

@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@delacry delacry left a comment

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.

Thank you, just one thing to resolve at elementAtOrNull(), and feel free to add similar checks to CollectionLogic, it might improve performance in some cases.

{
// @phpstan-ignore smaller.alwaysFalse (defensive guard: the phpdoc type does not bind untyped callers)
if ($index < 0) {
throw new IndexOutOfBoundsException('Cannot use a negative 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 would keep this guard, just return null instead of throwing, because now elementAtOrNull(-1) walks the whole source (or gets stuck if it's never-ending) before returning null

same guard would make sense maybe also in the CollectionLogic::elementAt()/elementAtOrNull(), so they don't walk all elements for a negative index

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.

done 2a1a242

Give every collection the positional vocabulary Sequence already had.
CollectionLogic walks to the position, ListLogic overrides it with the
random access it already has, so List, Set, MapKeySet, MapEntrySet and
MapValueCollection all gain it at once.

Align Sequence::elementAtOrNull() on the same answer for a negative
index: null rather than a throw. It is what ListInterface::getOrNull()
has always returned and what Kotlin returns, and it makes one rule
instead of two. The throwing elementAt() still rejects it.
A negative index made Sequence::elementAtOrNull() drain the source before
returning null, and never return on an infinite one. Collections know
their size, so they now reject any out-of-bounds index without walking.
@nikophil
nikophil force-pushed the feature/collection-element-at branch from 0ccea6a to 2a1a242 Compare September 28, 2026 13:48
@nikophil
nikophil requested a review from delacry September 28, 2026 13:54
The bounds check guarantees the walk finds the index, so the fallthrough
now throws a LogicException and is excluded from coverage.

@delacry delacry left a comment

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.

Thanks, looks good!

@delacry
delacry merged commit c2f76ab into noctud:0.2.x Sep 30, 2026
6 checks passed
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