[Pitch] Add `ends(with:)` to `BidirectionalCollection`

Add ends(with:) to BidirectionalCollection

Introduction

The standard library offers starts(with:) for checking whether a sequence begins with the elements of another sequence. There is no counterpart for testing whether a collection ends with another collection. This proposal adds ends(with:) to BidirectionalCollection, adopting the API that have been shipping in swift-algorithms.

Motivation

Checking a suffix is as common as checking a prefix and NSPredicate even exposes the operation as ENDSWITH, alongsideSTARTWITH.

Today, callers either depend on swift-algorithms or write the check by hand. Moving the operation into the standard library makes it discoverable next to starts(with:) and removes the need for a package dependency for a single comparison.

The change also unblocks ends(with:) support in Foundation's Predicate, which builds its supported sequence operations on the standard library. See the related proposal here.

Proposed solution

Add an ends(with:) method to BidirectionalCollection, plus an overload for elements that are Equatable, mirroring the existing starts(with:) and starts(with:by:) methods:

let name = "Johnson"
name.ends(with: "son") // true
[1, 2, 3].ends(with: [2, 3]) // true
[1, 2, 3].ends(with: [2, 3, 4]) // false

Detailed design

The implementation comes direct from https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/EndsWith.swift:

extension BidirectionalCollection {
    /// Returns a Boolean value indicating whether the final elements of the
    /// collection are equivalent to the elements in another collection, using
    /// the given predicate as the equivalence test.
    ///
    /// The predicate must be an *equivalence relation* over the elements. That
    /// is, for any elements `a`, `b`, and `c`, the following conditions must
    /// hold:
    ///
    /// - `areEquivalent(a, a)` is always `true`. (Reflexivity)
    /// - `areEquivalent(a, b)` implies `areEquivalent(b, a)`. (Symmetry)
    /// - If `areEquivalent(a, b)` and `areEquivalent(b, c)` are both `true`,
    ///   then `areEquivalent(a, c)` is also `true`. (Transitivity)
    ///
    /// - Parameters:
    ///   - possibleSuffix: A collection to compare to this collection.
    ///   - areEquivalent: A predicate that returns `true` if its two arguments
    ///     are equivalent; otherwise, `false`.
    /// - Returns: `true` if the final elements of the collection are equivalent
    ///   to the elements of `possibleSuffix`; otherwise, `false`. If
    ///   `possibleSuffix` has no elements, the return value is `true`.
    ///
    /// - Complexity: O(*m*), where *m* is the lesser of the length of the
    ///   collection and the length of `possibleSuffix`.
    @inlinable
    public func ends<PossibleSuffix: BidirectionalCollection>(
        with possibleSuffix: PossibleSuffix,
        by areEquivalent: (Element, PossibleSuffix.Element) throws -> Bool
    ) rethrows -> Bool {
        try self
            .reversed()
            .starts(with: possibleSuffix.reversed(), by: areEquivalent)
    }
}

extension BidirectionalCollection where Element: Equatable {
    /// Returns a Boolean value indicating whether the final elements of the
    /// collection are the same as the elements in another collection.
    ///
    /// This example tests whether one countable range ends with the elements
    /// of another countable range.
    ///
    ///     let a = 8...10
    ///     let b = 1...10
    ///
    ///     print(b.ends(with: a))
    ///     // Prints "true"
    ///
    /// Passing a collection with no elements as `possibleSuffix` always results
    /// in `true`.
    ///
    ///     print(b.ends(with: []))
    ///     // Prints "true"
    ///
    /// - Parameter possibleSuffix: A collection to compare to this collection.
    /// - Returns: `true` if the final elements of the collection are the same
    ///   as the elements of `possibleSuffix`; otherwise, `false`. If
    ///   `possibleSuffix` has no elements, the return value is `true`.
    ///
    /// - Complexity: O(*m*), where *m* is the lesser of the length of the
    ///   collection and the length of `possibleSuffix`.
    @inlinable
    public func ends<PossibleSuffix: BidirectionalCollection>(
        with possibleSuffix: PossibleSuffix
    ) -> Bool where PossibleSuffix.Element == Element {
        self.ends(with: possibleSuffix, by: ==)
    }
}

Source compatibility

Both ends(with:) and ends(with:by:) are new methods within the Standard Library; adding them is a source compatible change. Developers who currently import these methods from swift-algorithms (or define their own implementation with the same name), the imported/custom methods will still work, due to the shadowing rule for Standard Library names.

ABI compatibility

This proposal is purely additive to the Standard Library's ABI; the addition does not break any existing binary.

Alternatives considered

Move the whole set of counterparts from swift-algorithms

A small set of algorithms in swift-algorithms are near counterparts of existing APIs in the standard library:

This proposal describes ends(with:) only, but does not rule out adding the remaining algorithms, either as part of this proposal or in follow-ups.

6 Likes

I should open by saying that I've got no problem with sinking ends(with:) into the standard library—it's probably a good idea. However, I do think that from a policy perspective this isn't enough:

Everything in swift-algorithms is intended to be (a) broadly useful; and (b) "sinkable" into the standard library. Everything in that library would self-evidently become more discoverable in the standard library, and moving it there would remove a package dependency and unblock uses in clients that don't have that dependency.

These things were true from the moment these APIs were born. Yet these APIs weren't born into the standard library, and the rationale for having them in a separate library was articulated as follows:

So, to my mind, an argument for sinking an algorithm from the package into the standard library would address the following questions:

  • Has the algorithm been sufficiently iterated on? From these explorations, what have we learned about the problem space and how it connects and interacts with other algorithms? How do these learnings inform us that this algorithm is ready for graduation?

  • What has been the early community feedback? Has that feedback been sufficiently informed by real-world usage?

  • Is the algorithm part of a coherent 'tract' of missing functionality? If so, have we identified the remainder of that 'tract' and sufficiently explored it as above?

3 Likes

Thanks for the feedback @xwu!

To be honest, I don't think I have the knowledge and experience with swift-algorithms to answer these.
I came across the need of ends(with:) while trying to achieve parity between #Predicate and Notion filter data source entries api and realize that there's no PredicateExpressions .SequenceEndsWith (pitched here) and that's most likely because there isn't ends(with:) in the standard library.
I think the best I can add is that there's a small family of algorithms in swift-algorithms that are just very close counterparts of existing ones in the standard library:

4 Likes

A small note: in a C++ STL minimum requirements sense, this algorithm only requires the "needle" to be a Collection, not a BidirectionalCollection, at the cost of a call to count and three extra calls to advance. But I can't think of a case where the needle is not a BidiCollection already and is also so long that it can't be copied into one, so maybe we don't bother in order to keep the algorithm simple.

(Technically in the C++ STL minimum requirements sense this would live on Collection and just be O(N) by default, but I do think there's an advantage to forcing people to be explicit if they want that.)