Declaring local variables as lazy

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.

So, use what Swift already has:

  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
    {
      switch (defaultElement1, defaultElement2)
      {
        case (.none, .none):
          return Swift.min(distance1, distance2)
        case (.none, _):
          return Swift.min(distance1, max(distance1, distance2))
        case (_, .none):
          return Swift.min(max(distance1, distance2), distance2)
        case (_, _):
          return Swift.max(distance1, distance2)
      }
    }
    else
    {
      switch (defaultElement1, defaultElement2)
      {
        case (.none, .none):
          return Swift.max(distance1, distance2)
        case (.none, _):
          return Swift.max(distance1, min(distance1, distance2))
        case (_, .none):
          return Swift.max(min(distance1, distance2), distance2)
        case (_, _):
          return Swift.min(distance1, distance2)
      }
    }
  }

I don't think the argument was that it couldn't be done but that it would make for more readable code or less boilerplate.

@dennisvennink's example is rather concise and easy to read. Your's is a bit harder to grasp (but of course just as correct) don't you think? Highly subjective of course.

1 Like

I rather think the original version is the most concise and easy to read; since Swift.max is inlinable, I see no reason why the value has to be computed twice if the compiler is smart enough. Keep in mind as well that lazy desugars to something with its own performance cost.

Initially, this and other algorithms from the first versions of Zip2Collection made heavy use of switch, until I checked if they would scale to Zip3Collection. It turns out they didn't.

In your example for instance, you'll end up with 2n + 1 cases where n is the arity of the zip. So Zip3Collection would end up with 16 cases. I don't find this particularly readable, or maintainable.

2 Likes

I am at a loss to see the advantage of a lazy var here over a straightforward let:

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

let max = Swift.max(distance1, distance2) is executed precisely once and the result is used twice without the need for calling it a second time within its scope, which is the if block.

Even if it were lazily evaluated, max falls out of scope at the end of the block and would be released anyway, just as with a let.

I'm sorry but, unless I'm missing something profound here, I certainly couldn't use this example as justification for the pitch.

I think the point is that there is a case where neither will need the value max and thus the lazy version avoids even executing it a single time. Not really a big deal with max, but it might matter for a very expensive computation.

2 Likes

OK, in that case, avoiding the switch in case it is too "expensive", how about short-cutting the (nil, nil) case out?

  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
    {
      if defaultElement1 == nil && defaultElement2 == nil
      {
        return Swift.min(distance1, distance2)
      }
      
      let max = Swift.max(distance1, distance2)
      
      return Swift.min(defaultElement1 == nil ? distance1 : max,
                       defaultElement2 == nil ? distance2 : max)
    }
    else
    {
      if defaultElement1 == nil && defaultElement2 == nil
      {
        return Swift.max(distance1, distance2)
      }
      
      let min = Swift.min(distance1, distance2)
      
      return Swift.max(defaultElement1 == nil ? distance1 : min,
                       defaultElement2 == nil ? distance2 : min)
    }
  }

I still can't see the justification for a "lazy" var

1 Like

I'm very much +1 on support for "lazy" on local variables. Local variables should have the same capabilities as properties in structs and classes, global variables, etc. I consider this specific missing feature an engineering limitation based on how the compiler (at least used to) work, not something that designed to be this way.

-Chris

21 Likes

You can nest computed properties inside other functions, and the getter and setter bodies are proper closures which can capture values from the outer scope, for example:

func f(x: Int) {
  var p: Int { return x * x }
  print(p)
}

Thank you for that :grinning:

1 Like

Chris, pardon me for being a bit thick here but, so far, with the specific example given, I really can't see the need for a lazy local variable in that context.

Could you give us some idea of where it is genuinely useful ?