peder86
peder86

Reputation: 35

Chaining infix operators with statically resolved type parameter

I'm trying to create an infix operator to make System.Text.StringBuilder slightly easier to use.

I have the following inline function using statically resolved type parameters:

let inline append value builder = (^T : (member Append : _ -> ^T) (builder, value))

which handles all the overloads of StringBuilder.Append. This works fine as a regular function:

StringBuilder()
|> append 1
|> append " hello "
|> append 2m
|> string
// Result is: '1 hello 2'

When I try to use define an infix operator like so:

let inline (<<) builder value = append value builder

it works when all parameters in a chain are of the same type:

StringBuilder()
<< 1
<< 2
<< 3
|> string
// Result is: '123'

but fails with parameters of different types:

StringBuilder()
<< 1
<< "2"  // <- Syntax error, expected type 'int' but got 'string'.
<< 123m // <- Syntax error, expected type 'int' but got 'decimal'.

The expected type seems to be inferred by the first usage of the << operator in the chain. I would assume that each << would be applied separately.

If the chain is split into separate steps the compiler is happy again:

let b0 = StringBuilder()
let b1 = b0 << 1
let b2 = b1 << "2"
let b3 = b2 << 123m
b3 |> string
// Result is: '12123'

Is it possible to create such an operator?

Edit

A hacky "solution" seems to be to pipe intermediate results through the identity function whenever the type of the argument changes:

StringBuilder()
<< 1    // No piping needed here due to same type (int)
<< 2    |> id
<< "A"  |> id
<< 123m
|> string
// Result is: '12A123'

Upvotes: 2

Views: 145

Answers (4)

kaefer
kaefer

Reputation: 5741

I can possibly add a data point to this mystery, albeit I am only able to surmise that this behavior might have something to do with the particular overload for value: obj. If I uncomment that line and try to run it, the compiler says:

Script1.fsx(21,14): error FS0001: Type mismatch. Expecting a
    'a -> 'c    
but given a
    System.Text.StringBuilder -> System.Text.StringBuilder    
The type ''a' does not match the type 'System.Text.StringBuilder'

This happened while trying to map the various overloads of System.Text.StringBuilder to statically resolved type parameters on an operator. This seems to be a fairly standard technique in similar cases, since it will produce compile-time errors for unsupported types.

open System.Text
type Foo = Foo with
    static member ($) (Foo, x : bool)    = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : byte)    = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : char[])  = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : char)    = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : decimal) = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : float)   = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : float32) = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : int16)   = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : int32)   = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : int64)   = fun (b : StringBuilder) -> b.Append x
    // static member ($) (Foo, x : obj)     = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : sbyte)   = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : string)  = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : uint16)  = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : uint32)  = fun (b : StringBuilder) -> b.Append x
    static member ($) (Foo, x : uint64)  = fun (b : StringBuilder) -> b.Append x

let inline (<.<) b a =
    (Foo $ a) b
// val inline ( <.< ) :
//   b:'a -> a: ^b -> 'c
//     when (Foo or  ^b) : (static member ( $ ) : Foo *  ^b -> 'a -> 'c)

let res =
    StringBuilder()
    <.< 1
    <.< 2
    <.< 3
    <.< "af"
    <.< 2.32m
    |> string
// val res : string = "123af2,32"

Upvotes: 2

user1562155
user1562155

Reputation:

I think there is a solution in the following:

let inline append value builder = (^T: (member Append: _ -> ^S) (builder, value))
let inline (<<) builder value = append value builder

let builder = new StringBuilder()
let result = 
    builder
    << 1
    << " hello "
    << 2m
    |> string
printfn "%s" result

As seen the return value from Append is set to ^S instead of ^T and ^S is resolved to require Append as member.

It will find the correct overload for Append which you can see, it you use the following mockup of a StringBuilder:

type MyStringBuilder() =
    member this.Append(value: int) =
        printfn "int: %d" value;
        this
    member this.Append(value: string) =
        printfn "string: %s" value;
        this
    member this.Append(value: decimal) =
        printfn "decimal: %f" value;
        this
    member this.Append(value: obj) =
        printfn "obj: %A" value
        this

let builder = new MyStringBuilder()
let result = 
    builder
    << 1
    << " hello "
    << 2m
    |> string

Warning: There is though a peculiarity in the following setup:

let builder = StringBuilder()
let result = 
    builder
    << 1
    << " hello "
    << 2m
    << box " XX "
    |> string

when compiling this with the extra << box " XX " the compiler gets lost somewhere in the process and is rather long time to compile (only when using StringBuilder() - not MyStringBuilder()) and the intellisense and coloring etc. seems to disappear - in my Visual Studio 2019 as least.

  • At first, I thought it has something to do with the box value, but it rather seems to have something to do with the number of chained values???

Upvotes: 2

Tomas Petricek
Tomas Petricek

Reputation: 243051

This is quite odd - and I would say it may be a compiler bug. The fact that you can fix this by splitting the pipeline into separate let bindings is what makes me think this is a bug. In fact:

// The following does not work
(StringBuilder() << "A") << 1

// But the following does work
(let x = StringBuilder() << "A" in x) << 1

I think the compiler is somehow not able to figure out that the result is again just StringBuilder, which can have other Append members. A very hacky version of your operator would be:

let inline (<<) builder value = 
  append value builder |> unbox<StringBuilder>

This performs an unsafe cast to StringBuilder so that the return type is always StringBuilder. This makes your code work (and it chooses the right Append overlaods), but it also lets you write code that uses Append on non-StringBuilder things and this code will fail at runtime.

Upvotes: 3

Koenig Lear
Koenig Lear

Reputation: 2436

The below works:

let inline (<<) (builder:StringBuilder) (value:'T) = builder.Append(value)

let x = StringBuilder()
        << 1
        << 2
        << 3
        << "af"
        << 2.32m
        |> string

I think you need to be specific about the StringBuilder type otherwise it will pick only one of the overloads.

Upvotes: 1

Related Questions