Function builders and "including" let declarations in built result

Opening a separate mini thread, in parallel to @Douglas_Gregor's Function builders implementation progress about something we briefly discussed but wanted to get more eyes on and discuss in the open here.

Specifically, I'm playing around with function builders to build a DSL to replace some repetitive yet well-defined XML definition specification (so how it works is not up to me :wink: let's focus on "I need to represent this, and make feel good in Swift).

One of the main things I wanted to get right and improve the usability of this DSL is to be able to resolve "references" to definitions nicely. To explain this, let's look at an example, that showcases the same dependencies between nodes as the DSL. Imagine there's some Defs that are like schema definitions, and then there's things, which need to import them, and then refer to them, like so:

[use-case 1]

enum Defs { 
  let people = Def("people-id") {
    Field("name", .string)
    Field("surname", .string)
    Field("age", .int)
  }
}

Definition { 
  Thing { 
    // "import" the Def people, in order to be able to use it in this Thing
    // there may be many Defs, but this Thing only uses people 
    let people = Defs.people // <1>
    let other = Def.otherThings // <2>

    show { 
      people.name // <1>
      people.surname // <1>
      other.information // <2>
    }
    filter { 
      equals(people.age, 42)
    }
  Thing { ... } // uses other defs
}

For completeness, here's what the expected XML would be, but it does not really matter for the point of this discussion:

Expected output, once done with rendering the such declared DSL elements
  <def> 
  <id>some-id</id>
  <field><type>string</type><name>name</name></field>
  <field><type>string</type><name>surname</name></field>
  <field><type>int</type><name>age</name></field>
  </def>
  <!-- there can be many defs -->

<thing>
  <use-def>people-id</use-def>
  <use-def>other-id</use-def>
  <config>
    <show>$name</show>
    <show>$surname</show>
    <show>$age</show>
    <show>$information</show>
    <filter> 
      <equals><field>$age</field><value>18</value></equals>
    </filter>
  </config>
</thing>

Now, what does matter is the shape of this function builder in spots marked with <1> and <2> - today, this does not work. It is allowed to compile since let's are being passed-through (good!), but not only do I need to be able to refer to the people and other in shows, I also must emit a specific element for the fact that we'll be using people and other.

There's workarounds of course.

Workaround 1) "state" the declared field to emit into function builder

Thing {
  let people = Defs.people // passed "through" function builder as no-op
  people // passed TO function builder, and I can handle it.

  let other = Def.otherThings
  other

  show { 
    people.name
// ...

Needless to say this is not the best... it's easy to forget it, and looks arcane.

Workaround 2) "imports" can't be inside the builder, and must be functions

This is interesting and promising but has two limitations I can think of... Here's what it would mean API wise:

Thing(Defs.people, Defs.other) { people, other in 
  show { 
    people.name
  // ...

this would work, yes. However it comes at a cost of such declarations of Thing:

init() { ... }
init<D1: Def>(_ d1: D1) { ... }
init<D1: Def, D2: Def2>(_ d1: D1, _ d2: D2) { ... }
// and so on and on...

I would still be fine with that, taking a hit on boilerplate as library author can be fine. Naturally, varadic generics would be helpful here, but we don't have them today.

It becomes also harder if we're not talking only about "one" type of such import Def but a few of them... It's probably doable, but would require some tricky type dance, if someone were to want to write Thing(someDef, someSpecialThingThatMakesSenseHere, anotherDef) { def1, someting, def2 in ... though again, I could believe that it does not matter that much and we'd have to live with the tons of overloads, perhaps doable.

This does mean however that I always have to be nested when I want to refer to such things. I'm somewhat feeling nervous about this at some point breaking down, although I do have to admit for my personal use this does not seem to happen...

What would enable [use-case 1]: optionally allowing to handle lets in function builders

Now here's the question... And I know it can be a dividing one, but let's see where we end up with this.

If function builders would not only "pass through" let variable declarations, but also allow handling them, e.g. with a

public static func buildDeclaration(_ element: ThingElementConvertible) -> ThingElement? {
    if let def = element as? Def {
        return .importDef(def) // great, include it
    } else {
        return nil
    }
}

The sides of the coin here are, good:

  • because it gives my library control over those lets, and people can just write a def there, and I'll know how to "import it"
  • it is optional, and not implementing buildDeclaration would retain the current "pass through" behavior of lets
  • it is possible to mix someDef then someSpecialThingThatMakesSenseHere then anotherDef and it still can be correct (but if it should not be, I could throw/crash in the function builder to report "hey! all defs must be declared first but you tried to import a def after already writing someSpecialThingThatMakesSenseHere"

and bad:

  • the user of my library looses a bit of control here, they think they're including "Def" while actually, I'll store and render it differently (as some "def import"), in my specific case I view this as a feature, but perhaps it can be surprising or weird in some cases?
  • lets sometimes may not "pure" anymore, i.e. if I'd extract things into lets in a random function builder, it COULD BE that I changed what it will construct.
    • E.g. Hello(Nested) COULD (but does not have to, since this feature is optional) be different than let nested = Nested(); let hello = Hello(nested) if a function builder was implemented to "accept lets of Nested/Hello" I could then end up with "nested + nested in hello" rather than "nested IN hello".
    • I would argue though that this is context dependent... some DSLs will want to avoid this type of function builder feature, and some won't (e.g. I guess it'd be weird in SwiftUI, which is today the pass-through mode makes sense as the default and only way).
    • It can then be argued that it gets "confusing" but IMO... with building DSLs some manner of following "that's how you do it" is expected...

So yeah, hope that's an interesting discussion point, and let's see what other uses and/or thoughts people might have about this. As mentioned, I have workarounds but not fully in love with them, so wanted to discuss in a wider forum, if others are also building custom DSLs where this could be beneficial.

3 Likes

( This is now being discussed in [Pitch #2] Function builders - #72 by Douglas_Gregor )