تحرير

Extension members for SRTP constraints

An inline function with statically resolved type parameters can require a member with a particular name and signature. F# 11.2 lets an in-scope extension member satisfy that requirement, even when the type comes from another assembly.

Use this capability to separate a generic algorithm from adapters for particular types. You don't need to wrap those types or add a nominal interface to them.

Note

In the .NET 11.0.100 SDK, enable <LangVersion>preview</LangVersion> where the compiler resolves extensions. See F# 11.2 language settings for the planned feature band. The path and mapping examples use an F# 11.0 library and a preview consumer; see Compile libraries and consumers.

Adapt a property for a shared algorithm

The following application code measures paths represented by two existing framework types. Both paths have a length of 8:

open System.Numerics
open Structural.Adapters
open Structural.Algorithms

let complexRoute =
    [ Complex(0.0, 0.0); Complex(3.0, 4.0); Complex(6.0, 4.0) ]
let vectorRoute =
    [ Vector2(0.0f, 0.0f); Vector2(3.0f, 4.0f); Vector2(6.0f, 4.0f) ]

printn (pathLength complexRoute) // 8
printn (pathLength vectorRoute)  // 8

The library source defines Structural.Adapters and Structural.Algorithms. The adapters expose the same coordinate property on types with different native APIs:

type System.Numerics.Complex with
    member point.Coordinates = point.Real, point.Imaginary

type System.Numerics.Vector2 with
    member point.Coordinates = float point.X, float point.Y

The algorithm asks for a Coordinates property returning a pair of floating-point values:

let inline coordinates (point: ^Point) : float * float =
    (^Point : (member Coordinates: float * float) point)

let inline pathLength (points: ^Point list) =
    points
    |> List.pairwise
    |> List.sumBy (fun (a, b) ->
        let ax, ay = coordinates a
        let bx, by = coordinates b
        let dx, dy = bx - ax, by - ay
        sqrt (dx * dx + dy * dy))

At each concrete call, the compiler selects a Coordinates member, called the constraint's witness. The inline algorithm accepts either an intrinsic or extension property; empty and single-point paths have length zero.

Share an operation across collection types

Extension constraints also support static members. The library's Structural.Adapters module defines adapters using the existing List.map and Option.map functions:

type List<'T> with
    static member Map(source: 'T list, mapping: 'T -> 'U) : 'U list =
        List.map mapping source

type Option<'T> with
    static member Map(source: 'T option, mapping: 'T -> 'U) : 'U option =
        Option.map mapping source

In the library's Structural.Algorithms module, request a Map member on the complete input type:

let inline map (mapping: 'T -> 'U) (source: ^Input) : ^Output =
    (^Input :
        (static member Map: ^Input * ('T -> 'U) -> ^Output)
        (source, mapping))

The application can then share a transformation, change the element type, and compose mapping over a nested collection:

let inline cleanNames values =
    map (fun (name: string) -> name.Trim().ToUpperInvariant()) values

let batch: string list = cleanNames [ " Ada "; " grace " ]
let optional: string option = cleanNames (Some " linus ")
let missing: string option = cleanNames (None: string option)
let lengths: int list = map String.length batch
let nested: int option list = map (map String.length) [ Some "abc"; None ]

printfn "%A; %A; %A" batch optional missing
printfn "%A; %A" lengths nested
// ["ADA"; "GRACE"]; Some "LINUS"; None
// [3; 5]; [Some 3; None]

The compiler checks member names and types, not interfaces or mapping laws. This isn't higher-kinded polymorphism: no parameter represents a container constructor such as F<_>.

Operator extensions

Operators can use the same mechanism. For example, define a mapping operator for lists and options:

type List<'T> with
    static member (|>>) (source: 'T list, mapping: 'T -> 'U) =
        List.map mapping source

type Option<'T> with
    static member (|>>) (source: 'T option, mapping: 'T -> 'U) =
        Option.map mapping source

let inline (|>>) (source: ^Input) (mapping: 'T -> 'U) : ^Output =
    (^Input :
        (static member (|>>): ^Input * ('T -> 'U) -> ^Output)
        (source, mapping))

A consumer opens StructuralOperators and uses the operator:

open StructuralOperators

let incremented: int list = [1; 2; 3] |>> ((+) 10)
let length: int option = Some "hello" |>> String.length
// [11; 12; 13] and Some 5

Built-in primitive operator solutions retain priority for matching types. An extension doesn't provide arbitrary mixed-numeric widening or replace all built-in operators.

F# 11.0 mode can accept an ordinary extension-operator declaration with FS1215, but can't use it as an SRTP solution. The operator project uses preview.

Tuple operators and context

A collection of observations and a pair of independently accumulated results can participate in the same combination protocol. The following consumer defines its generic algorithm after opening the library's adapters:

open Composition.Adapters
open Composition.Algorithms

let inline combineAll (batches: ^T list) : ^T =
    List.reduce (++) batches

let observations = combineAll [["temperature"]; ["pressure"; "humidity"]]
let summary = combineAll [(2, ["temperature"]); (3, ["pressure"; "humidity"])]

printfn "%A" observations
printfn "%A" summary
// ["temperature"; "pressure"; "humidity"]
// (5, ["temperature"; "pressure"; "humidity"])

These library extensions give the operator a meaning for integers, lists, and pairs:

type System.Int32 with
    static member (++) (left: int, right: int) = left + right

type List<'T> with
    static member (++) (left: 'T list, right: 'T list) = left @ right

type ('T1 * 'T2) with
    static member inline (++) ((a, b), (c, d)) = (a ++ c, b ++ d)

combineAll requires a nonempty list because it uses List.reduce. The operation preserves list order; the compiler doesn't enforce associativity or supply an identity for an empty input.

Native headers type ('T1 * 'T2) with and type struct ('T1 * 'T2) with extend CLR reference and value tuples. Both are F# 11.2 features and support two through seven elements; larger headers produce FS3915.

Tuples can also carry context alongside a value. The following operator appends a trail while applying a contextual function:

let priced =
    (["catalogue price"], fun units -> decimal units * 12m)
        <*> (["validated quantity"], 3)

printfn "%A" priced
// (["catalogue price"; "validated quantity"], 36M)
type ('Context * 'Value) with
    static member inline (<*>) ((before, mapping), (after, value)) =
        (before ++ after, mapping value)

type struct ('Context * 'Value) with
    static member inline (<*>) (struct (before, mapping), struct (after, value)) =
        struct (before ++ after, mapping value)

Tuple dot-instance calls and native function-type headers such as type ('a -> 'b) with aren't supported.

The composition library and consumer both use preview. Keep combineAll in the consumer: exporting that reduction can produce FS0193 for tuple ++. The separate path and mapping examples export their algorithms.

Return-directed construction

For an operation that introduces a plain value into a container, the output type can identify which member should construct it:

type List<'T> with
    static member Return(value: 'T) : 'T list = [value]

type Option<'T> with
    static member Return(value: 'T) : 'T option = Some value
let inline singleton (value: 'T) : ^Output =
    (^Output : (static member Return: 'T -> ^Output) value)
let oneBatch: string list = singleton "ready"
let oneOptional: string option = singleton "ready"
// ["ready"] and Some "ready"

The consumer's result annotation selects the output type's Return extension. The input alone can't choose a container.

Each container supplies its own member. For return-only overloads on a single type, use the separate AllowOverloadOnReturnType attribute.

Compile libraries and consumers

Language settings apply per project, not transitively through references. The following matrix uses the .NET 11.0.100 SDK's settings for F# 11.2 features:

Library implementation Consumer use Preview requirement
Declares native tuple-extension headers. Resolves their extension operators. Both need preview; the new header syntax itself is preview.
Defines ordinary extensions and exports an unresolved inline member constraint. Supplies concrete types whose extensions solve that constraint. Consumer needs preview. The library can use F# 11.0.
Solves an extension constraint internally and exposes an ordinary or already-resolved inline API. Calls that API without further extension resolution. Library needs preview; the consumer can use F# 11.0.
Solves extensions and exports constraints that the consumer also resolves through extensions. Resolves remaining constraints. Both need preview.

The path and mapping library uses 11.0; its consumer uses preview. From the consumer directory, run dotnet run with a supporting F# 11 SDK.

Extension scope across assemblies

Use public extensions and open their module before defining dependent inline functions. For an imported unresolved constraint, also open the adapters in the consumer; the producer's open declarations don't transfer. An already-resolved API doesn't require the consumer to reopen its adapters.

Inference and signatures

F# 11.2 resolution defers some choices that F# 11.0 mode makes earlier. Consider an inline helper defined with a DateTime receiver:

let inline shift (date: System.DateTime) offset = date + offset

In F# 11.0 mode, this form can infer DateTime -> TimeSpan -> DateTime. With F# 11.2 extension resolution enabled, it can retain a constraint for (DateTime or ^Offset) : (static member (+) : DateTime * ^Offset -> ^Result). A consumer can then select a public, in-scope extension for a different offset type.

A consumer can't recover generality lost in an F# 11.0 library. Check exported signatures; the producer might also need F# 11.2 inference enabled.

Preview limitations and troubleshooting

  • For FS0001 or FS0193, check language settings, public visibility, and adapter scope.
  • If a later producer file resolves the path example for Complex, code generation can fail with FS0971, Undefined value 'get_Coordinates'. Keep that resolution with its definitions or in a separate consumer.
  • Competing adapters can make member selection ambiguous.

See also