apple / swift-algorithms

Commonly used sequence and collection algorithms for Swift
Apache License 2.0
5.97k stars 443 forks source link

A `first` operator that returns the first non-nil transformed value #21

Closed Qata closed 3 years ago

Qata commented 4 years ago

I found myself often having to find the first element in an array that passes an optional initialiser. If I wanted to stick to a purely functional paradigm (which I do, I'm a zealot) I need to do some pretty not-ideal transforms to allow this behaviour without defining a new function.

let value = ["Hello", "10"].lazy.map(Int.init).first { $0 != nil }.flatMap { $0 }

This could be trivialised with the following (naive) implementation.

public extension Sequence {
    /// Returns the first element in `self` that `transform` maps to a `.some`.
    func first<Result>(of transform: (Element) throws -> Result?) rethrows -> Result? {
        for value in self {
            if let value = try transform(value) {
                return value
            }
        }
        return nil
    }
}
pyrtsa commented 4 years ago

That would be a useful addition!

The current alternative could be code golfed down to this though:

let value = ["Hello", "10"].lazy.compactMap(Int.init).first
ktoso commented 4 years ago

What you're proposing is fairly close to Scala's collectFirst by the way: https://www.scala-lang.org/api/current/scala/collection/Seq.html#collectFirst[B](pf:PartialFunction[A,B]):Option[B]

I'm bringing it up mostly because I really really miss collect (which is the same but over the entire collection), it's tremendously useful.

func collect<Result>(_ transform: (Element) -> Result?) -> [Result]
func collectFirst<Result>(_ transform: (Element) -> Result?) -> Result?

Perhaps collect and collectFirst could be a thing to consider?

pyrtsa commented 4 years ago

@ktoso I think you mixed up the type signatures? And if you did, isn't func collect<R>(_ transform: (Element) -> R?) -> [R] what we already know as compactMap(_:)?

ktoso commented 4 years ago

Ah thanks, fixed the swapped signatures.

And if you did, isn't func collect( transform: (Element) -> R?) -> [R] what we already know as compactMap(:)?

Hmm, I guess by "swift-ifying" the function I dropped the a core feature of collect actually: the partial function. So since we don't have those in Swift, the collect kind of misses a lot of the power and devolves into a devolves into a compactMap as you rightfully observed there hm -- too bad hm. For reference how usage looks like there:

(1 :: 2 :: 3 :: 10 :: Nil) collect { // PartialFunction can be expressed as pattern match 
    case 1  => "yes-1"
    case 10 => "yes-10"
} // List("yes-1", "yes-10")

Feel free to ignore my sidetracking comment 🙂

timvermeulen commented 4 years ago

Big +1, I've wanted (and used) this operation for years. The hardest part about adding it is coming up with a proper name. Lately I've been calling it firstMap, mainly because I believe "first" and "map" should both be part of the name, and mapFirst is not really a serious contender.

Other suggestions I've heard go around:

I'm aware of prior art in just 2 other languages:

natecook1000 commented 4 years ago

This feels right on the cusp, given that it's composable via .lazy.compactMap(f).first, as pointed out by @pyrtsa. On the plus side are these factors:

Any other notes in favor? Is there a correctness or performance improvement over the .lazy approach?

timvermeulen commented 4 years ago

given that it's composable via .lazy.compactMap(f).first

It is often overlooked that calling this on a (non-Collection) Sequence isn't lazy, because first doesn't exist on Sequence, so that compactMap is secretly the eager one returning an array.

natecook1000 commented 4 years ago

It is often overlooked that calling this on a (non-Collection) Sequence isn't lazy, because first doesn't exist on Sequence, so that compactMap is secretly the eager one returning an array.

That's a great point!

Qata commented 4 years ago

@natecook1000 What's the next step from here? Does naming bike-shedding happen on this issue or on an actual PR?

natecook1000 commented 4 years ago

The name is definitely the trickiest part here, but I think we can add this with our best bet and move forward that way. Let's start with firstNonNil(_:) and go from there. Thanks!

Qata commented 4 years ago

Let the bikeshedding commence #31