Declaring local variables as lazy

Oftentimes one has code that uses the same values in multiple branches of an if statement. DRY would dictate that those values are only instantiated in the code once. Example code:

func f() {
    let x1 = resultOfExpensiveComputationWithNoSideEffects1()
    let x2 = resultOfExpensiveComputationWithNoSideEffects2()

    if cond1 && cond2 {
        // use x1 and x2
    } else if cond1 {
        // use x1
    } else if cond2 {
        // use x2
    } else {
        // use neither
    }
    // x1 and x2 are not used after this point
}

While the initialization of x1 and x2 before the if block makes for cleaner code, x1 and x2 will be created regardless of whether they are actually needed, which means that the expensive computations may be performed needlessly. The alternative would be to write out let x1 = resultOfExpensiveComputationWithNoSideEffects1() inside each branch that x1 is used (and likewise for x2), but this violates DRY. There is no way to rearrange the if block to write the initialization of each of those variables only once.

Proposal: If x1 and x2 could be declared as lazy var (or even lazy let, as the restrictions of init no longer apply), then the expensive computations would only be performed if the branches of the if block requiring them were actually taken.

10 Likes

With the existing features of Swift, this is not really needed. Remember that we have first class closures and nested functions (that can capture parent context). For example, you can simply do this:

func f() {
    let x1 = { resultOfExpensiveComputationWithNoSideEffects1() }
    let x2 = { resultOfExpensiveComputationWithNoSideEffects2() }

    if cond1 && cond2 {
        // use x1() and x2()
    } else if cond1 {
        // use x1()
    } else if cond2 {
        // use x2()
    } else {
        // use neither
    }
    // x1 and x2 are not used after this point
}

The difference in extra typing and visual noise is not high enough to justify adding this to the language.

4 Likes

The closure based solution can be improved by writing a simple caching wrapper to ensure that the expensive computations are not accidentally called more than once by using x1() more than once in an expression:

/**

 `Lazy` allows to create values from potentially expensive computations
 lazily while guaranteeing that the computation is only run once.
 
 - Note: accessing `value` is thread safe
 
 */
class Lazy<Result> {
    
    private var computation: () -> Result
    lazy var value: Result = computation()
    
    init(_ computation: @escaping @autoclosure () -> Result) {
        self.computation = computation
    }
}

func expensiveComputation() -> Int {
    print("calculating...")
    return 42
}

let x = Lazy(expensiveComputation())

x.value + x.value
2 Likes

This is a cumbersome solution if you need to use the result more than once in a branch. You have to store the result in a separate variable to avoid calling the closure and performing the calculation multiple times.

The Lazy wrapper from @trs works, but again is somewhat cumbersome -- I'd like to be able to do this without creating a wrapper class in every project that needs it.

Allowing lazy local variables feels like a very natural solution to me. From a design standpoint, it seems arbitrary that you can use lazy on a property but not a local variable. I was honestly a little surprised that it couldn't be done when I found myself needing it.

8 Likes

Why does this argument not apply equally to the contexts in which lazy is currently allowed?

Allowing lazy local variables is a complication only from the perspective of the compiler writers. For users of the language, it's a simplification and a unification. Why should users be required to memorize a list of special cases where lazy is allowed?

8 Likes

If you use x1 twice within one branch then you need another variable to store the result -- let y1 = x1() + x1() would still be problematic, so you'd need to do let x3 = x1(); let y = x3 + x3. That's quite noisy.

This is ugly. You shouldn't have to remember whether you're dealing with lazy variables or not at the site of use (you certainly don't have to for class/struct members).

1 Like

This may show my ignorance, but couldn't the unused calls be optimized away by a whole module optimizer? Of course the called funcs would have to be in the same module.

The compiler doesn't always know which branch will be taken at compile time. When the choice of branch is only knowable at runtime, you need to create the variables lazily. As a quick test I wrote the following function:

 f() {
    print("f began")
    let x = (0...100000000).reduce(0, +) // Takes a while
    print("computed sum?")
    let response = readLine()

    if let response = response, response.count > 0 {
        print(response, x)
    } else {
        print("Nothing entered")
    }
}

There is a long time between printing "f began" and "computed sum?" regardless of whether a response is entered. The sum could be left uncomputed when the user doesn't enter any text, but currently it is computed no matter what.

I did not think that the optimizer did optimize it away, but it theoretically could. I am not sure that it would be worth the effort.

Here is another option that does work:

func f() {
    func makeX() -> Int {
        print("f began")
        return (0...100000000).reduce(0, +) // Takes a while
        print("computed sum?")
    }

    let response = readLine()

    if let response = response, response.count > 0 {
        print(response, makeX())
    } else {
        print("Nothing entered")
    }
}

I came across a situation where I wanted this functionality today. We all seem to agree that lazy local variables are something useful. @bob's final points still remain unrefuted.

I'm keen to help with this. Is there any compelling reason why we shouldn't progress to a formal proposal?

I think the main reason its not supported is that it would require a bit of work to implement -- 'lazy' properties desugar to a computed property that uses an underlying stored property with optional type under the hood. This desugaring currently assumes there's a 'self' value to hang the stored property off of, however it should be possible to do the same thing inside a function body by adding the hidden stored property to the BraceStmt containing the lazy property. We already support computed properties nested inside functions and accessors can have captures, so there should not be any issues there.

2 Likes

It's already possible to use willSet/didSet on local variables. I was quite surprised when I learned about that, and imho it's a strong argument to allow lazy as well:
Even if it makes the compiler more complicated, it simplifies Swift for its users by removing an exception.

3 Likes

I don't think it will be more complicated overall, it just requires moving bits of code around to get it to work. It would be a medium-sized starter project, if anyone is interested in taking a look I can get them started.

3 Likes

I might take you up on that offer and would appreciate help from you or anyone else. I tried looking but couldn’t easily find a “here’s a brief overview of how the compiler works to get starters going” video or document?

3 Likes

Since the method in question simply "passes through" an if ladder once only, what you are asking for is not lazy vars but what is known as a static variable in C++, that retains its value between calls to the function.

Assuming func f() is part of a class or struct, why would you want to have "lazy" vars inside the method when you could equally have standard lets outside of the method but inside of the class or struct?

Even if you are writing a "global" function, you could still declare lets outside of the function and read them from within.

Hi Slava, I cannot see the need for this pitch (see my reply) but could you explain "accessors can have captures"?

Here's a real world use case (that could end up in the Standard Library).

It overrides the default O(n) implementation of distance(from:to:) with an O(1) implementation for a zip that takes default values:

func distance (from start: Index, to end: Index) -> Int {
  if start == end {
    return 0
  }

  let distance1 = collection1.distance(from: start.index1, to: end.index1)
  let distance2 = collection2.distance(from: start.index2, to: end.index2)

  if start < end {
    return Swift.min(
      defaultElement1 == nil ? distance1 : Swift.max(distance1, distance2),
      defaultElement2 == nil ? distance2 : Swift.max(distance1, distance2)
    )
  } else {
    return Swift.max(
      defaultElement1 == nil ? distance1 : Swift.min(distance1, distance2),
      defaultElement2 == nil ? distance2 : Swift.min(distance1, distance2)
    )
  }
}

The ternary operator already helps us out here; if no defaultElements exist, the calls to Swift.max(_:_:) and Swift.min(_:_:) will not be made. However, if both Collections have defaultElements, then either will be computed twice.

With lazy vars this could be rewritten as:

func distance (from start: Index, to end: Index) -> Int {
  if start == end {
    return 0
  }

  let distance1 = collection1.distance(from: start.index1, to: end.index1)
  let distance2 = collection2.distance(from: start.index2, to: end.index2)

  if start < end {
    lazy var max = Swift.max(distance1, distance2)

    return Swift.min(
      defaultElement1 == nil ? distance1 : max,
      defaultElement2 == nil ? distance2 : max
    )
  } else {
    lazy var min = Swift.min(distance1, distance2)

    return Swift.max(
      defaultElement1 == nil ? distance1 : min,
      defaultElement2 == nil ? distance2 : min
    )
  }
}

It might very well be that the cost of using lazy var here outweighs the potential duplicated calls to min(_:_:) and max(_:_:), since these are not the most computationally heavy calculations. But in cases where the calculative cost matters, local lazy variables would be incredibly useful.

Re: workarounds. Closures and function wrappers will only work if the variable it's wrapping is only used once. Breaking the variable out into the namespace above it, pollutes it and is considered bad practice. A lazy struct wrapper is the most elegant hack, but has extra cognitive overhead.

This should be in the language.

8 Likes

You could imagine f taking arguments and the long computations being passed f’s arguments in which case the long computations would have to reside in f.

Why do you need lazy vars for this example?

Surely, if you replace those in your code with simple lets, you get the same effect.

After all, max and min are only ever called once per invocation of distance(::)?

If defaultElement1 == nil && defaultElement2 == nil then you don't really need to compute Swift.max(distance1, distance2), but you'll end up doing so anyway. So yes, they're called once per invocation; we want them to not be called at all, if possible.