Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a String-focused Computation Expression Syntax #1149

Open
5 tasks done
SchlenkR opened this issue Jun 3, 2022 · 10 comments
Open
5 tasks done

Add a String-focused Computation Expression Syntax #1149

SchlenkR opened this issue Jun 3, 2022 · 10 comments

Comments

@SchlenkR
Copy link

SchlenkR commented Jun 3, 2022

I propose a string-focussed syntax for computation expressions.

Example:

// a given model...
let customer = 
    {|
        name = "Best Plants"
        orders = [
            {| id = 1; qty = 10; isDelivered = false |}
            {| id = 2; qty = 20; isDelivered = false |}
            {| id = 3; qty = 30; isDelivered = true |}
        ]
    |}

// ... can be used in a string-focussed CE:
let result = $"""
Good day, {customer.name}! Here are your orders:
===
{for order in customer.orders do}

ID: {order.id}
Quantity: {order.qty}
Status: {if order.isDelivered then}You got it.{else}ON OUR WAY!{end}
{done}

End of report.
"""

The result is:

Good day, Best Plants! Here are your orders:
===

ID: 1
Quantity: 10
Status: >>> ON OUR WAY!

Good night.
ID: 2
Quantity: 20
Status: >>> ON OUR WAY!

Good night.
ID: 3
Quantity: 30
Status: You got it.

End of report.

The above can be seen as:

  • A new way for expressing text templates.
  • An extension of current string interpolations with imperative constructs like if and for.
  • A new syntax for a certain set of computation expressions (see below).

Looking from a CE point of view, this proposal is about a syntax sugar for the StringBuffer CE, which is discussed here. A computation expressed in the proposed string-focussed syntax can be transpiled into the current CE syntax using StringBuffer:

stringBuffer {
    $"
Good day, {customer.name}! Here are your orders:
===
"

    for order in customer.orders do
        $"""
ID: %i{order.id}
Quantity: %i{order.qty}
Status: {if order.isDelivered then "You got it." else "ON OUR WAY!"}
"""

    "
End of report.
"
}

Notes

  • Inside of F# expressions, indentation should not be significant. Instead, there has to be an alternative way for indicating scopes. In the example above, the tokens {done} and {end} are used.
  • Provide a possibility of specifying line ending and disacrding empty lines or 'expression-only'-lines

Current alternatives:

  • Imperative string concatenation
  • StringBuffer CE
  • String interpolations
  • String template engines like Mustache, Handlebars, Razor, etc., which are not type safe or convenient to use from F#.

Benefits:

  • Type safe templates
  • Having a complete language-integrated string templating engine that works in similar ways as another first class language feature (computation expressions).
  • Increased writeability and readability of text templates.

Extra information

Estimated cost (XS, S, M, L, XL, XXL): L to XL

Related suggestions: #775

Please tick this by placing a cross in the box:

  • This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • This is not something which has obviously "already been decided" in previous versions of F#. If you're questioning a fundamental design decision that has obviously already been taken (e.g. "Make F# untyped") then please don't submit it.

Please tick all that apply:

  • This is not a breaking change to the F# language design
  • I or my company would be willing to help implement and/or test this

For Readers

If you would like to see this issue implemented, please click the 👍 emoji on this issue. These counts are used to generally order the suggestions by engagement.

@LyndonGingerich
Copy link

I'm not sure everyone would find this syntax useful enough to be worth the increased complexity. Why not write a library instead?

@SchlenkR
Copy link
Author

SchlenkR commented Jun 7, 2022

I'm not sure everyone would find this syntax useful enough...

I'm sure not everyone would find this syntax useful enough.

:)


From a users point of view, the proposal would decrease complexity and simplify building strings in many cases.

This following code makes this clearer, which uses string interpolation to render a list of elements:

let names = [ "Iris"; "Magda" ]
$"""<grid>{names |> List.map (fun name -> $"<row>User: {name}</row>") |> String.concat ""}</grid>"""

This gives: <grid><row>User: Iris</row><row>User: Magda</row></grid>

As known from CEs, map can be represented using a for comprehension, so that we can find another representation for the initial problem expression, which could be:

let names = [ "Iris"; "Magda" ]
$"""<grid>{for name in names do}<row>User: {name}</row>{done}</grid>"""

I assert that the 2nd representation is a simpler form than the initial one, where "simpler" means "easier to write and read", and that the way of thinking in the 2nd case is very well known from computation expressions. This gets even clearer when the level of nesting increases, or when the template becomes more complex, e.g. by line breaks or growth in size.

In general, this proposal is about extending string interpolations from "concatenating strings and isolated (nested) expressions" to "yielding strings from within a computational context (=CE)". Why one would want to have this in addition to having just current string interpolation? Propably for the same reasons for why one would want to have CEs in the language in addition to fun, app or let (BTW, why would one want or need let :) ).

Why not write a library instead?

This is a very general question. A library is of course possible, and could be useful, but there are disadvantages, like: No proof of correct tempalte usage by the compiler, higher burden of usage, not idiomatic, no intellisense, etc.

Or, to answer the question with another question, here it comes:

Why is this:

$"Hello, {user.name}, how are you?"

better than this:

$"Hello, " + user.name + ", how are you?"

The answer is:

String interpolation improves readability and reduces errors by staying visually closer to the end result.

@LyndonGingerich
Copy link

LyndonGingerich commented Jun 7, 2022

Pardon. By "complexity", I meant "how many syntaxes you have to learn", not "how complex your syntax is for any particular use case". I'm not sure it's enough of an improvement over the stringBuffer example to warrant making everyone learn a new way of doing it, increasing the burden of use of F# as a language. Simplicity and ease of learning are among F#'s greatest strengths.

@LyndonGingerich
Copy link

LyndonGingerich commented Jun 7, 2022

Methods of string generation for comparison:

A plausible string CE using string interpolation:

stringBuffer {

    $"""
Good day, %s{customer.name}! Here are your orders:
===
"""

    for order in customer.orders do
        let status = if order.isDelivered then "You got it." else "ON OUR WAY!"

        $"""
ID: %i{order.id}
Quantity: %i{order.qty}
Status: %s{status}
"""

    """
End of report.
"""

}

The proposed CE:

$"""
Good day, {customer.name}! Here are your orders:
===
{for order in customer.orders do}

ID: {order.id}
Quantity: {order.qty}
Status: {if order.isDelivered then}You got it.{else}ON OUR WAY!{end}
{done}

End of report.
"""

With no CE:

module order =
    let report order =
        let status =
            if order.isDelivered then
                "You got it."
            else
                "ON OUR WAY!"

        $"
ID: %i{order.id}
Quantity: %i{order.qty}
Status: %s{status}"

module customer =
    let report customer =
        let orders = System.String.Join("\n", List.map order.report customer.orders)

        $"
Good day, %s{customer.name}! Here are your orders:
===
%s{orders}

End of report."

@SchlenkR
Copy link
Author

SchlenkR commented Jun 7, 2022

Thanks @the-not-mad-psychologist , I understand, and questioning new ways of expressing things that can already be expressed in the language is important. I guess that the majority of suggestions is about new ways for representing things, rather than enabling things one could not express before (which is propably hard to find in a complete programming language).

In this particular case, it's true that this is about a new way of representing string building that can currently be achieved in other ways. In my opinion, it is worth the effort for this case.

A note on the "learning things" part: I think one doesn't have to learn a complete new feature, because it's possible to think of the proposed syntax as an extension of string interpolations - it's an addition. It allows a developer to evolve if there is a need or not. Also, from a conceptual view, the idea is well-known from CEs. If one understand them, the transition to the proposal is a low barrier, since no new concepts have to be learned.

I'm also not absolutely sure about those things; they are suggestions. In this case, the proposal is driven by my latest experiences that involved a lot of text templating tasks. The quoted statement from the string interpolation RFC ("...improves readability and reduces errors by staying visually closer to the end result.") was my motivation for more complex templates involing loops and conditionals.

@dsyme
Copy link
Collaborator

dsyme commented Jun 9, 2022

@the-not-mad-psychologist I took the liberty of removing the yield from your stringBuffer example

I think suggestions to make interpolated strings be even more powerful as comprehensions do make sense. However for the above the lack of indentation for the for and the need for explicit done both look like a fairly chronic problems.

One option might be to allow interpolands to include control flow and nested interpolations, e.g.

e.g.

$"""...{if condition then string-generating-expr}..."""
$"""...{while condition do string-generating-expr}..."""
$"""...{for v in coll do string-generating-expr}..."""
$"""...{string-generating-expr1; string-generating-expr2}..."""
$"""...{match input with pat -> string-generating-expr}..."""
$"""...{let v = rhs in string-generating-expr}..."""

e.g.

$"""hello{if condition then
              $"world at {DateTime.Now}"
          if otherCondition then
              $"there at {DateTimeOffset.Now}"
          while condition do
              $"one line"
} and some more here"""

Leading to this:

$"""
Good day, {customer.name}! Here are your orders:
===
{for order in customer.orders do $"

ID: {order.id}
Quantity: {order.qty}
Status: {if order.isDelivered then "You got it." else "ON OUR WAY!" "}

End of report.
"""

Needs more nesting of interpolations than currently allowed by F# and may not be a coherent design.

@dsyme
Copy link
Collaborator

dsyme commented Jun 9, 2022

Also a question if you could only generate strings - currently interpolands with no % can be any objects. So maybe you should be allowed to generate any obj if you don't specify a type:

let coll = [0..100]
$"""The integers are {for x in coll do x; ","} when comma-separated"""```
$"""The integers are {for x in coll do x; ";"} when semicolon-separated"""```

However there are just some comprehension patterns that are very specific to common cases particular to strings which don't drop out that well in generative comprehensions. For example the above adds a trailing ";" and ",", and if you want to avoid this the options aren't so great:

$"""The integers are {
        let mutable first = true
        for x in coll do
            if not first then ","
            first <- false
            x} when comma-separated"""

And this is neither obvious nor particularly elegant:

$"""The integers are {for i, x in List.indexed coll do x; if i <> 0 then ","} when comma-separated"""

And maybe you really want some kind of CE-custom-operator-like-thing:

$"""The integers are {for x in coll do x; separator ","} when comma-separated"""```

@SchlenkR
Copy link
Author

SchlenkR commented Jun 10, 2022

I think that the power of interpolations come from the pattern "interrupt string, then continue string".
An alternative way of handling the indentation / scope termination problem could be using curly braces for scopes. That would be:

// "holes" as of today (with for, if, etc. not as control construct)
$"""...{string-generating-expr}..."""

// control constructs
// 'do', '->', 'in', 'then' are omitted here
$"""...{for x in xs{...}..."""
$"""...{match x with pat1{...{|pat2{...}..."""
$"""...{let x = 10{...}..."""
$"""...{while cond{...}..."""
$"""...{if cond{...}..."""
$"""...{if cond{...{else{...}..."""
$"""...{custom-operation{...}..."""

// not needed:
$"""...{string-generating-expr1; string-generating-expr2}..."""
// because it can be expressed with
$"""...{string-generating-expr1}{string-generating-expr2}..."""

Control constructs can be used by string "broken" by {, continued by { and closed by }.

Examples / Alternatives

This:

$"""The integers are {for x in coll do x; ","} when comma-separated"""
$"""The integers are {for x in coll do x; ";"} when semicolon-separated"""

could be this:

$"""The integers are {for x in coll{{x},} when comma-separated"""
$"""The integers are {for x in coll{{x};} when semicolon-separated"""

Formatting can be applied in the usual way:

$"""The integers are {for x in coll{%i{x},} when comma-separated"""
$"""The integers are {for x in coll{%i{x};} when semicolon-separated"""

The initial example:

$"""
Good day, {customer.name}! Here are your orders:
===
{for order in customer.orders{

ID: {order.id}
Quantity: {order.qty}
Status: {if order.isDelivered{You got it.{else{ON OUR WAY!}
}
"""

Comprehension patterns

For a way of expressing patterns like joining, edge case handling (...whenEmpty), etc., CSS pseudo selectors come in my mind, which handle a similar problem in an acceptable way. Expressing patterns could be achieved by small descriptors which are provided alongside the seq expression in 'for'. This is the idea:

type PatternDescriptor =
    | Join of sep:string
    // others; find a way of mixing patterns in a meaningful way

type TextBuilder () =
    member _.Yield(txt: string) = txt
    member _.Combine(f: string, g: string) = f + g
    member _.Delay(f: unit -> string) = f()
    member _.Run(f: string) = f
    member _.Zero() = ""
    member inline _.For((xs: #seq<'a>, Join sep), f: 'a -> string) =
        xs |> Seq.map f |> String.concat sep
    member this.For(xs: 'a seq, f: 'a -> string) =
        this.For((xs, Join ""), f)

let text = TextBuilder()

// using CE
text {
    "Integers: "
    for x in [1..3], Join("; ") do
        $"Number-%i{x}"
}

// using string interpolations
$"""Integers: {for x in [1..3], Join("; "){Number-%i{x}}"""

// Gives:
// "Integers: Number-1; Number-2; Number-3"

@LyndonGingerich
Copy link

Control constructs can be used by string "broken" by {, continued by { and closed by }.

I must protest. The very use of brackets implies that each open-bracket is to be matched by a close-bracket. If we want a continuation operator, we should use a different symbol.

@SchlenkR
Copy link
Author

What about this:

  • @ interrupts a string and starts a control-flow-expression.
  • { to continue the string in a new scope.
  • } to end the current scope.

That would look pretty close to C#'s Razor:

$"""Integers: @for x in [1..3]{Number-%i{x}}""" 

In that case, it wouldn't be possible to use CEs in a control flow expression. Since it's about convenience for practical string building cases and not obscure constructs (meaning: Starting CEs inside string interpolations will not be supported), that might be ok.

@dsyme dsyme changed the title Add a String-focussed Computation Expression Syntax Add a String-focused Computation Expression Syntax Jun 16, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants