"outer let" to limit scope of temporary definitions in a nicer way


(Amir Michail) #1

For example:

do {
  let t = ...
  outer let x = f(t)
}

NSLog("x=\(x)")

... which is nicer than...

let x: Double
do {
  let t = ...
  x = f(t)
}

NSLog("x=\(x)")

Perhaps one could use "outer" in other contexts as well such as the branches of an if:

if ... {
  outer let x = ...
} else {
  outer let x = ...
}

instead of

let x: Double
if ... {
  x = ...
} else {
  x = ...
}

#2

My question is, how far outer would x be? Especially in a case with deeply nested blocks.


(Amir Michail) #3

Outer would go out one level but perhaps you could use a label to go out more. For example:

label: do {
    do {
      outer label let x = ...
    }
}

(Dante Broggi) #4

I do not (currently) think this is a good idea, but syntactically I think I would prefer:

label2: do {
  public label: do {
    public let x = …
  }
  _ = label.x
}
_ = label2.label.x

#5

I think you'd have to make an actual argument that it is nicer, and why. The current syntax has the advantage of being really clear about what the scope is.

If anything, the enhancement I'd want is:

let x
do {
  let t = ...
  x = f(t)
}

(where the type of x is inferred, instead of needing an explicit type annotation).


(Happy Human Pointer) #6

What about this:

do {
  outer let x = try make()
}
catch {
  print(error)
}

Where the scope is not existed on an error. In this case, x is not initialized.

There would have to be some sort of complete warning here. I am in favor of this pitch, but the fact that we already can do this without any sugar is edging me against it.

This would require the compiler to look X amount of steps ahead for the type. It also allows things like this:

let x
if someBool {
  x = 1
} else {
  x = "y"
}
// is x Int or Bool

Swift is not dynamically types so this would be a problem.


#7

I just want to point out that it's not an insurmountable problem, because rust looks ahead to infer types. It would be awesome to have that feature in swift too.

https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=b014bde76f0efad01ac80828019336b4

    let x;
    if some_bool {
        x = 1;
    } else {
        x = "y".to_string();
    }

produces error

error[E0308]: mismatched types
 --> src/lib.rs:7:13
  |
7 |         x = "y".to_string();
  |             ^^^^^^^^^^^^^^^ expected integral variable, found struct `std::string::String`
  |
  = note: expected type `{integer}`
             found type `std::string::String`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.

#8

There already is. This compiles just fine:

    func test () {
        let x: Int
        do {
            x = try someFunc()
            print(x)
        }
        catch {
            print("failed")
        }
    }

because x isn't used outside the sub-scope where it is initialized. This fails to compile:

    func test () {
        let x: Int
        do {
            x = try someFunc()
        }
        catch {
            print("failed")
        }
        print(x)
    }

because the compiler knows it's wrong.


(Karim Nassar) #9

Wholly agree with @QuinceyMorris ‘s approach. It’s the need to forward-declare the type that’s annoying, especially in cases where the type is somewhat complex.

This approach would have the benefit of high-readability: the reader of the code (whether the conpiler or a teammate) knows exactly where the var/let is being scoped.


(Davide De Franceschi) #10

Just as a note, this syntax already exists in Swift: see "Labeled Statements" in https://docs.swift.org/swift-book/LanguageGuide/ControlFlow.html#ID135


#11

In Rust, you can declare a variable inside of a scope, e.g.

let a = {
  1 + 2 // implicit return
};

I think that a similar syntax for Swift would solve the general use case requested here in a nice way:

let a = do {
  return try throwingFunction()
} catch {
  return nil
}

#12

One approach here would be to improve the compiler’s ability to infer the type of multi-line closures. Then we could write:

let x = {
  let t = ...
  return f(t)
}()

Essentially, the type-checker would do a simple linear scan, line by line. If each individual statement type-checks, then proceed to the next. If any piece is ambiguous, then the whole closure is ambiguous.

Thus, parameters to the closure are constrained by the first statement that uses them, and the return value is determined by the type of the return line. I don’t know how feasible that is based on the compiler’s existing structure, but if it can be made to work then I think it would be quite useful.


(Happy Human Pointer) #13

What if I have a closure like this:

{
  if somePred(foo) {
    return error
    do { return try someFun() }
    catch { return error }
  }
  return try! someFun()
}

What is the expected behavior? If you are asking me, the type checker should choose someFun's return type as the return type. However, if we use this step by step method, then Error would be the return type. I feel like this is more of a choice of preference rather than a set method.

We will have to agree on a method for the closures return type.


(Happy Human Pointer) #14

What about let a = try? throwingFunction()?


#15

I'm aware that is already a Swift feature; I was just presenting a simple example to illustrate the functionality of the syntax.


#16

This is ambiguous unless someFun’s return type is the same as error.


(Jordan Rose) #17

I don't want to rain on anyone's parade, but type inference across statements would be a major change in how Swift does things; it's also likely to be a source compat break if you apply it to multi-line closures no matter how hard we try. The reason Rust (and Haskell, and several other languages) can get away with it is that they don't have overloading (by type or by default arguments), which makes the problem of type-checking a lot simpler. In Swift, we've kept it limited to a single statement so that both the compiler and the developer can know that once a type is defined, its type is fixed.

I will say that a limited way that model could be extended without full-on multi-statement type-checking would be the syntax @QuinceyMorris brought up.

let x
do {
  let t = ...
  x = f(t)
}

In this case, nothing can depend on the type of x until it is set, and so it'd be possible for the compiler to infer it based on the first assignment. If there's more than one "first assignment", the rest would have to agree with the first. (If I recall correctly there's a similar rule for the inferred return type of an Objective-C block.)


#18

I want to be clear that what I was describing above does not involve type inference across statements, at least the way I imagine it.

In a multi-line closure, the compiler would infer types one statement at a time, just like it does today. If it reaches a line where it cannot infer the types involved, then an error is raised at that line and the entire closure is ambiguous.

The first time it reaches a return statement in the closure, it infers the return type of the closure from that. If it reaches another return statement for which it infers a different type, then an error is raised at that line and the entire closure is ambiguous.


(Karoy Lorentey) #19

It seems to me that such an outer let would invariably make code harder to follow, by allowing declarations-at-a-distance. It would be especially confusing when combined with shadowing.

E.g., would this compile?

func x(_ x: Int) -> Int {
  do {
    if let x = f(x) { // which of the four `x`s gets passed to `f`?
      outer let x = try g(x) // What's the `x` in `g(x)`?
    } 
    else {
      outer let x = try g(x) // How about now?
    }
    outer let x = x + 1
  }
  catch {
    outer let x = h(x)
  }
  return x
}

The current syntax may take slightly longer to type, but I find it a lot easier to understand, and it prevents such abominations.


(Jordan Rose) #20

The tricky bit is knowing what the parameter types are. In some cases that's easily derivable from context (including when there are no parameters); in others the developer writes them explicitly; but if neither of those are the case then you end up with the very first line of the closure failing, rather than treating it as a problem of the context that's using the closure.