Pitch: Scoped Functions

Agreed. The HTML example was added because it is "trendy", but it does not hold much water. I'll remove it from the pitch.

1 Like

Yes, key paths are very powerful. But:

  • Both $0 and key paths create visual noise that this pitch aims at removing:

    let label = build(UILabel()) {
        \.text <= "Hello world"
        \.font <= .systemFont(ofSize: 12.0)
    }
    
    let label = build(UILabel()) {
        $0.text = "Hello world"
        $0.font = .systemFont(ofSize: 12.0)
    }
    
    // Please
    let label = build(UILabel()) {
        .text = "Hello world"
        .font = .systemFont(ofSize: 12.0)
    }
    
  • It is not always correct that key paths are a suitable tool.

    In the database query example, they look quite OK at first sight:

    // Vapor's Fluent
    Galaxy.query(on: conn).filter(\.name == "Milky Way")
    

    The problem with key paths is that when one wants to be able to write a query based on some column, the model type must expose this column as a property. This prevents model type from hiding database implementation details. Some models would prefer to only expose a coordinate: CLLocationCoordinate2D property and keep their latitude and longitude private. Some models would prefer to expose a price: Decimal property, and keep their database storage private: a priceCents integer column allows SQLite to compute exact sums, for example. Yet, queries on individual coordinates and price must be expressible.

    I argue that being able to decouple the columns that can feed the query language, from the properties, is desirable. This is how model types can become the one and only layer that interfaces the database and the rest of the app.

    That's why I prefer:

    // GRDB + Scoped functions
    Galaxy.filter(.name == "Milky Way")
    

    For this to be possible, I'd like to tell the compiler where to look for name (here, in the static properties of Galaxy). That's one of the practical goals of the pitch.

6 Likes

Agreed.
Typical instance creation on the fly would be one of them:

let label: UILabel = {
    let label: UILabel = .init()
    label.text = "Hello world"
    label.font = .systemFont(ofSize: 12.0)
    return label
}()
let label: UILabel = {
    $0.text = "Hello world"
    $0.font = .systemFont(ofSize: 12.0)
    return $0
}(UILabel())
lazy var label: UILabel = makeLabel()

private func makeLabel() -> UILabel {
    let label: UILabel = .init()
    label.text = "Hello world"
    label.font = .systemFont(ofSize: 12.0)
    return label
}

And everytime I write one of them, I feel it's noisy. It's good if we could write simpler.
To be honest, I always wanted to write them much more simpler. The last one is acceptable though.

Yet, I'm not sure if proposed

build(_:_:)

would fit into Swift standard (although I understand this is just showing one possible use case).
I mean, this kind of global function feels a bit... hacky.


Also, I agree it's nice if this kind of expression is accepted at compiler level.

Galaxy.filter(.name == "Milky Way")

Despite this is accepted by compiler:

Galaxies.filter(\.isEmpty)

This is not:

Galaxies.filter(!isEmpty)
// nor
Galaxies.filter(!\.isEmpty)

which feels awkward. I understand the former is kind of just by-product and if you understand why or how it works, there is no wonder though.

(There's user-end workaround though):

prefix func !<T>(keyPath: KeyPath<T, Bool>) -> (T) -> Bool {
    return { !$0[keyPath: keyPath] }
}

enables:

Galaxies.filter(!\.isEmpty)

I think one point is if this kind of expression should be implemented at compiler level or not.

...

However, if you need to use leading dot, what would you think prefix operators would work with @scope?

1 Like

I thought a bit about that and then I had the idea, that we should be able to write such an API in current Swift already by using dynamic member lookup. I built this little example but unfortunately it doesn't work:

@dynamicMemberLookup
struct Comparison<T, U> where U: Comparable {
    private let lhs: KeyPath<T, U>
    private let rhs: U!
    private let op: ((U, U) -> Bool)! // Could also be an enum or something like that, which could be converted into an SQL query comparison...

    static subscript(dynamicMember keyPath: KeyPath<T, U>) -> Comparison<T, U> {
        return .init(lhs: keyPath, rhs: nil, op: nil)
    }

    static func ==(lhs: Self, rhs: U) -> Self {
        .init(lhs: lhs.lhs, rhs: rhs, op: ==)
    }

    static func >(lhs: Self, rhs: U) -> Self {
        .init(lhs: lhs.lhs, rhs: rhs, op: >)
    }

    func apply(to value: T) -> Bool {
        self.op(value[keyPath: self.lhs], self.rhs)
    }
}

struct Galaxy {
    let name: String
    let numberOfStars: Int

    static func getAll() -> [Galaxy] {
        // ...
    }

    static func filter<T>(_ isIncluded: Comparison<Galaxy, T>) -> [Galaxy] {
        let galaxies = getAll()
        return galaxies.filter(isIncluded.apply(to:))
    }
}

let stringComparison = Comparison<Galaxy, String>.name == "Milky Way" // Works
let intComparison = Comparison<Galaxy, Int>.numberOfStars == 100_000_000_000 // Works

let stringComparison2: Comparison<Galaxy, String> = .name == "Milky Way" // Doesn't work
let intComparison2: Comparison<Galaxy, Int> = .numberOfStars == 100_000_000_000 // Doesn't work


let filteredGalaxies = Galaxy.filter(Comparison.name == "Milky Way") // Works

let filteredGalaxies2 = Galaxy.filter(.name == "Milky Way") // Doesn't work

Apparently implicit member lookup is currently not working for dynamic members. I don't know if that is just a bug or an oversight, or if there really is a limitation why that cannot work, but if it could work, it would be a better way than bringing scoped functions into Swift AND introducing auto closures which take an argument (which would have to be a completely new proposal and I really doubt that it would get accepted...).
Basically I'm saying that we should look into why this implicit member lookup isn't working. @Jumhyn could you say something about that? AFAIK you worked on the last implicit member lookup proposal.

EDIT:

I checked it without dynamic member lookup... it does indeed work, if we add this extension:

extension Comparison where T == Galaxy {
    static var name: Comparison<Galaxy, String> {
        .init(lhs: \.name, rhs: nil, op: nil)
    }

    static var numberOfStars: Comparison<Galaxy, Int> {
        .init(lhs: \.numberOfStars, rhs: nil, op: nil)
    }
}

But obviously that is not the way to go. We need the implicit member lookup to work with dynamic members as well, if we want a nice API...

2 Likes

This proposal is centered around the syntax of how scoped function could look like, but it doesn’t go into why they should exist in the first place. I would rather take the angle of defining it first, have a design centered around the definition and syntax will naturally follow.

If we simplify closures for a moment as unnamed functions, let’s take a step back and explore what scoped functions in general mean: functions that are defined in the scope a given type. We already have that language feature, functions in any extension are basically scoped functions, where self refers to the instance of the extended type.

I expect scoped closures to behave similarly, in parity with extension functions. When you refer to a property or a function in a scoped closure, the compiler should search these on ‘self’, the type you defined your scoped closure on. (The natural syntax derived from this doesn’t even need the proposed leading dot, as self should refer to the scoped type directly)

In reality, closures are much more capable than simple functions, they capture context, they can escape, etc. So the real questions is how do you reach things out of your scope, how do you redefine self conveniently, so that it won’t become awkward and ambiguous to the reader, how do you not accidentally shadow properties on self in the scoped closure, and how do you refer to a different scope.
I believe we should focus on this question rather than the actual syntax.

(I’m really glad we’re having this conversation again, apply/with comes up every once in a while, which suggests we all think of some really powerful concept we just currently can’t express in Swift.)

4 Likes

Agreed. Such functions make it possible to talk about the feature, but they are not part of the pitch.

They work, without any extra methods, operator, or whatever. We're dealing with regular values, with their regular types, which happen to profit from a shortcut syntax. In the below snippet, !, ==, >=, &&, etc. are already provided by GRDB on all expressions:

Galaxy.filter(.isEmpty)
Galaxy.filter(!.isEmpty)
Galaxy.filter(.name == "Milky Way")
Galaxy.filter(.radius >= 50000 && .starCount >= 1.0e11)

// Code above is interpreted as:
Galaxy.filter(Galaxy.isEmpty)
Galaxy.filter(!Galaxy.isEmpty)
Galaxy.filter(Galaxy.name == "Milky Way")
Galaxy.filter(Galaxy.radius >= 50000 && Galaxy.starCount >= 1.0e11)

// Replacing static properties with their values:
Galaxy.filter(Column("isEmpty"))
Galaxy.filter(!Column("isEmpty"))
Galaxy.filter(Column("name") == "Milky Way")
Galaxy.filter(Column("radius") >= 50000 && Column("starCount") >= 1.0e11)

Scoped functions would allow the first group to compile out of the box, without any extra support code.

1 Like

You're right: the pitch still has a weak introduction and motivation. I'm happy the conversation makes it possible to refine and focus it.

I would now answer your question this way:

The pitch aims at changing the way we look at this code snippet:

func f(_ x: Int) { ... }
f(.max) // Implicit Int

Currently, we see a type, Int, be granted with a short-hand syntax. There is an implicit and obvious context that allows the reader of the code to understand what .max means.

Now, the way we understand the code is the same, whether we say:

  1. .max means Int.max because the argument of f is an Int.
  2. .max means Int.max because f accepts an Int argument.

I thus propose that we shift from (1) to (2), and see the origin of the short-hand syntax in the function, instead of the type of its argument.

It is the function that has the intent, the meaning, the documentation, and drives the context. Not the types of its arguments.

Type-based leading dot syntax is, for functions, a mean to make this context implicit at call site.

From this new point of view, it is natural to desire to free functions from the types of their arguments when they want to provide short-hand syntax. And this is what scoped functions achieve. Functions become able to freely decide the implicit context. This makes it possible to design more fluent apis.


The rest of your post seems to suggest that we redefine self inside the body of a closure. I did not follow this path, because I did not want to have to deal with all the shadowing challenges this would create (and that you foresee as well):

let text = "foo"
let label = UILabel()
with(label) {
    text = text // 😭
}

The leading dot syntax avoids this shadowing.

Having a shadowed self is better than having a shadowed .text, because in the former case you can always refer to the underlying scope instance explicitly if needed.

// proposed leading dot syntax
with(label1) {
  with(label2) {
    .text  // no way to refer to the label1 scope instance
           // or to the label2 scope instance
  }
}

// custom 'self' scope
with(label1) {
  with(label2) { [scope1 = self] in
    self       // refers to with(label2)'s scope
    self.text  // refers to with(label2)'s scope.text
    text       // refers to with(label2)'s scope.text
    scope1     // refers to with(label1)'s scope
  }
}
4 Likes

Perhaps—In many situations what you call "visual noise" I consider an important indicator that something 'special' is happening that makes the code much easier to read and understand.

Consider: in theory, Swift 1.0 could have had implicit member syntax which didn't use the leading dot at all, so that

func f(_: Int) {}

f(max)

would work as f(.max) does today, with a rule akin to:

If the compiler can infer the contextual type of an unqualified identifier, then name lookup will search for results in that type's static members before starting to look in the local scope.

IMO, this would have been a confusing way to design the feature. It would conflate the qualified and unqualified lookup systems in a way that would make it difficult for programmers to reason about how a particular name would be resolved just by looking at the code. I.e., qualified lookup is 'different enough' from unqualified lookup that we want to make sure the difference between the two is always visible in the source.

Similarly, "scoped functions" are, IMO different enough from both qualified and unqualified lookup (sort of a hybrid between the two) that they would deserve their own signifier. I also don't believe that this scoping should be allowed in autoclosure'd expressions since it would make the introduction of a new scope entirely transparent to the library user.

7 Likes

For inspiration: Kotlin solves the same scoping problem by redefining this (self) and they assign compiler generated scope labels that allow access to outer scopes if needed. This expressions—Kotlin

At first I was happy to see this, but when I follow the link I see it's under the "Alternatives Considered" section, not the "Future Directions" section. So maybe it has been ruled out? I hope not.

I think there are two separate uses for this feature. One is like Kotlin's with/apply - a quick shorthand to avoid repeating the name of an object. The second use is for more powerful DSLs. SwiftUI is an example.

I've used Kotlin a lot, and while I like the way they use this for DSLs, I think... maybe... the Swift function builders have more potential. I'd like to see them developed further, and see this scoping ability added there (to Swift function builders).

I'm a -1 on the leading dot. :) To me it's confusing that it mixes with the existing meaning.

3 Likes

A related thought: I would be very interested in seeing an approach like the function builders extended to expressions, so that a library could receive an expression from the caller unevaluated, in AST form.

1 Like

I have wanted this for so long. I have tried to wrap my mind around how it would work for quite a while. Especially since I HATE how Builder functions work by requiring almost the exclusive use of functions that return the object to manipulate objects. It is very un-Swifty.

This would turn

{
Body()
.setInnerText("<div/>")
.setFont(Font())
}

Into:

{
Body() {.innerText = "<div>"; .font = Font(); .whatEver}
}

Correct? If so, bravo. You fixed one of the ugliest parts of SwiftUI/Builder functions.

1 Like

SwiftUI is built specifically in that way because the order in which modifiers are applied matters: setting a view's frame and then adding a border is different than adding a border first and then setting the frame. Scoped functions would unlikely change that.

5 Likes

SwiftUI is only one use-case for builders, so I think we do understand why SwiftUI is implemented that way, but it is currently the ONLY way to use Builders. You have to return something on every line, thus the only way to modify an object is through functions that either return self or return a new object.

What I mean is that I have built many SwiftUI like libraries that build interfaces but have functions that only return exactly self just so I can use 'declarative' syntax --- which is annoying. I just want to modify a property, and we have great syntax for that e.g. something.property = thing. We shouldn't need to write functions which only purpose is to set a property and return self just to use declarative syntax. To me, this proposal is way more desired than Builders, but regardless is an amazing compliment to Builders -- it allows us to mix the best of both worlds while keeping succinct syntax. It might also lead to solving some of the pitfalls of Builders especially when it comes to control flow -- for which Builders are not well equipped today.

I give this proposal 1000000+ points.

There was a very similar pitch back in 2019 Receiver Closures, which was also inspired by another Kotlin feature: Function literals with receiver
And I do hope that either one of these pitches will be considered and eventually implemented.

Perhaps the main point to address here is this:

My 2 cents here. Many (real-world) concepts and domains can be elegantly abstracted with a DSL. Having worked with Kotlin I've contributed to some DSLs that were really pleasant to use, which also could be understood by programmers and non-programmes alike, because of the familiarity with the domain.

This feature would be a niche, as it is in Kotlin, however it would allow library authors to provide very very convenient and elegant APIs.

Instead of the usual HTML example, I would like to show the API I'd like to provide for SwiftkubeModel for building Kubernetes API objects programmatically:

This just a simple example, check out this k8s-kotlin-dsl repository for a complete K8s DSL built with Kotlin using function receivers:

let nginx = deployment {
  metadata {
    name = "nginx"
    labels = ["app": "nginx"]
  }
  spec {
    replicas = 2
    selector {
      matchLabels(["app": "nginx"])
    }
    template {
      spec {
        containers {
          conrainer("nginx") {
            image = "nginx:latest"
          }
        }
      }
    }
  }
}

it is readable, type-safe, auto-completable, and above all it is familiar for everyone working with K8s.

4 Likes

Insouciant pitch: Why not use |. as the sigil?

// The build(_:configure:) function accepts a closure 
// scoped on its first argument:
let label = build(UILabel()) {
    |.text = "Hello scoped functions!"
    |.font = .preferredFont(forTextStyle: .body)
}

// The `filter(_:)` and `order(_:)` functions accept an 
// autoclosure scoped on its receiver type.
// `Player` collaborates, and has defined two
// `Player.score` and `Player.team` static constants.
let players = try Player
    .filter(|.team == "Red")
    .order(|.score.desc)
    .limit(10)
    .fetchAll(from: database)

// A mix and match between result builders and
// scoped functions. Each closure is scoped on an
// object that defines the relevant leading-dot apis 
// inside the body.
let html = HTML {
    |.head { |.title("Hello scoped functions!") }
    |.body { ... }
}

// A revisit of SE-0299: the argument of toggleStyle(_:) is
// an autoclosure scoped on an ad-hoc enum type:
Toggle("Wi-Fi", isOn: $isWiFiEnabled)
  .toggleStyle(|.switch)

@Jumhyn mentioned its hybrid nature, it made me think of a sigil "between" \. and ..

I agree that using a different sigil would be the best option. I'm not sold on the alternate sigil proposed ', though.

I wish that • was easy to type on Windows keyboards, I would have liked that to be the sigil that we used instead. A period like sigil feels right.

It's mostly already possible with some result builder trickery, but it doesn't look like an ergonomic solution.

build(UILabel()) {
    \.text <- "Hello scoped functions!"
    \.font <- .preferredFont(forTextStyle: .body)
}

// implementation is below

@resultBuilder
enum Builder<T> {
    static func buildBlock(_ items: T...) -> [T] { items }
    static func buildExpression(_ expression: T) -> T { expression }
}

struct Modification<T> {
    let perform: (inout T) -> Void
}

func build<T>(_ initialValue: T, @Builder<Modification<T>> modifications: () -> [Modification<T>]) -> T {
    modifications().reduce(into: initialValue, annotate)
}

func annotate<T>(value: inout T, with modification: Modification<T>) {
    modification.perform(&value)
}

infix operator <-

extension WritableKeyPath {
    static func <- (lhs: WritableKeyPath<Root, Value>, rhs: Value) -> Modification<Root> {
        Modification {
            $0[keyPath: lhs] = rhs
        }
    }
}

I continue to believe that this would be the clearest way to express scoped functions:

with(UILabel()) {
    text = "Hello scoped functions!"
    font = .preferredFont(forTextStyle: .body)
}

Agreed that "without prefix" looks elegant.
And the point would be how variable shadowing should be treated I think, like mentioned in the first quote.

While those are simple and elegant, however, I wonder how those would be translated:

Something like into this?:

Galaxy.filter(isEmpty)
Galaxy.filter(!isEmpty)
Galaxy.filter(name == "Milky Way")
Galaxy.filter(radius >= 50000 && starCount >= 1.0e11)

  • With leading .
  • Direct access to property inside @scoped (no prefix)

In contrast to above two ideas, I believe it will be very hard to convince people to accept custom prefix that doesn't exist in Swift stdlib today, if this pitch becomes an actual proposal and went under review.

Terms of Service

Privacy Policy

Cookie Policy