Appending elements to a Lazy Collection?

It doesn't seem like the LazyMapCollection (and friends) have mutating methods. Is there a way around this without converting to an array first?

var numbers = [7, 1, 8, 2, 3, 9, 4, 5, 6]

var stuff = numbers.lazy.filter { $0 % 2 == 0 }.filter { $0 > 4 }.map(String.init)

stuff.append(10)  <--- compile error LazyMapCollection<etc..> has no member append.

Do you still have access to the original thing you lazified? Then you can just mutate that, and re-apply the lazy processing (at which point it probably would make sense to wrap that operation in another method so you don't have to repeat yourself).

I don't think it would make sense for lazy collections to have mutating methods directly on the thing they're wrapping, that would be very confusing. It would be better for them to let you access that wrapped thing in order to mutate it. Lazy types don't expose the thing they wrap (the std lib uses the term base fairly consistently for the wrapped thing). It might be a good idea to add a computed property that served up that base.

But mutating it also has challenges:

First, it's likely the wrapped thing is a copy-on-write type. That means it'll probably be multiply referenced, so mutating it will trigger the copy you're trying to avoid. Of course, that's likely to happen even if you mutate the original, which now has a copy inside the lazy wrapper, unless you're careful to make sure that goes out of scope before you do the mutation.

Assuming the original array has gone out of scope, the good thing is we now have _modify, so at least stuff.base.append(10) could mutate the base without triggering CoW.

The other problem is that there's multiple layers of wrapping. So in your example, you'd have to write stuff.base.base.base.append(10) with a naïve implementation of base. I don't know if there's a good way around this, because each layer adds another wrapper type. But maybe it's possible to construct a property that strips away all the lazy wrappers to give you the underlying type at the bottom.

2 Likes

Very interesting. Thanks for the response! I didn't think to mutate the original array, but it also would require changing the code around quite a bit. Maybe the appending operations can happen before all the lazy processing operations... :thinking:

Something else I came across while doing this:

If I have a function that applies lazy operations and has a return type with a non Lazy* type, the function applies all the lazy operations immediately.

func doStuff(numbers: [Int]) -> [String] {
    return numbers.lazy
                    .filter { $0 % 2 == 0 }
                    .filter { $0 > 4 }
                    .map(String.init)
}

let stuff = doStuff(numbers:  [7, 1, 8, 2, 3, 9, 4, 5, 6])

Is there some special treatment for Lazy types here?

Now let's say i store it in a variable first:

func doStuff(numbers: [Int]) -> [String] {
    let result = numbers.lazy
                    .filter { $0 % 2 == 0 }
                    .filter { $0 > 4 }
                    .map(String.init)
    return result    <------------- error: Cannot convert return expression of type 'LazyMapCollection<LazyFilterCollection<LazyFilterCollection<[Int]>>, String>' to return type '[String]'
}

let stuff = doStuff(numbers:  [7, 1, 8, 2, 3, 9, 4, 5, 6])

Of course, if I update the return type to the LazyMapCollection<... etc. > it will remain lazy.

Thought this was interesting.

No, this is just Swift's overload resolution at work.

Lazy sequences are still sequences: there is a LazySequence protocol they conform to that refines Sequence. It has filter and map defined on it to perform those operations lazily. But since it also conforms to Sequence, the eager versions are there too. They're just shadowed. Swift's overload resolution rules are to prefer the "more specific" ones i.e. they'll pick a method on a concrete type over one provided by a protocol it also conforms to, and the one from a child protocol over a parent protocol. So normally, when you filter a LazyFilteredCollection, it stays in lazy-land.

But you can force Swift to choose a different overload via type context. So this will compile:

let a: [Int] = [1,2,3].lazy.map { $0*2 }

What's happening here is the compiler throws out the overload that would otherwise be preferred, which is LazySequence.map(_:) -> LazyMapSequence, because that won't type check. So it tries what's left, and ends up calling Sequence.map(_:) -> Array.

In your function example, it is the return type of doStuff being a [String] that is providing that type context. The return statement will not compile if the type checker were to return a LazyMapSequence, so it rules out that overload. Instead, it picks Sequence.map.

In your second example, it's similar to writing this:

let a = [1,2,3].lazy.map { $0*2 }
// a is a LazyMapSequence<[Int], Int>
let b: [Int] = a // this won't compile
2 Likes

Hit the same problem. Chaining seems to work for me. Solves both problems I had with mutating original collection, avoiding new allocations and not having the appendage items in original collection element type.

chain(
    a.lazy.map { e -> T in ... },
    b.compactMap { e -> T? in ... }
)
4 Likes