fsharp icon indicating copy to clipboard operation
fsharp copied to clipboard

[<TailCall>] attribute gives incorrect warning in many examples

Open Bananas-Are-Yellow opened this issue 5 months ago • 14 comments

This code compiles fine:

    [<TailCall>]
    let rec fib a b = seq {
        yield a
        yield! fib b (a + b)
    }

    let fibonacci = fib 0 1

But the following minor change produces the message: The member or function 'fib' has the 'TailCallAttribute' attribute, but is not being used in a tail recursive way.

    [<TailCall>]
    let rec fib a b = seq {
        yield a
        if true then
            yield! fib b (a + b)
    }

    let fibonacci = fib 0 1

Related information

Provide any related information (optional):

  • Pop!_OS 22.04
  • VS Code: 1.102.2
  • TargetFramework: net9.0

Bananas-Are-Yellow avatar Jul 27 '25 18:07 Bananas-Are-Yellow

FSharp.Core (9.0.300)

Bananas-Are-Yellow avatar Jul 27 '25 19:07 Bananas-Are-Yellow

This issue might be related to #16477, which is fixed and closed.

As with #16477, if I reverse the if test, I don't get the warning. So, the following code gives the warning:

    [<TailCall>]
    let rec fib a b = seq {
        yield a
        if true then
            yield! fib b (a + b)
    }

But this code does not give the warning.

    [<TailCall>]
    let rec fib a b = seq {
        yield a
        if false then () else
            yield! fib b (a + b)
    }

Also, as with #16477, this example only happens with a Debug build.

However, my actual code is slightly more complicated and still gives warnings in a Release build. The code compares two sorted arrays using a compare function.

Because I can't apply the [<TailCall>] attribute to a nested function (attributes on nested functions are not supported), I have the recursive function inside a separate type.

type Diff<'a,'b> =
    | A of 'a
    | B of 'b
    | AB of 'a * 'b

module SortedArray =
    type private CompareWith<'a,'b> (compare, itemsA: array<'a>, itemsB: array<'b>) =
        let countA = Array.length itemsA
        let countB = Array.length itemsB

        [<TailCall>]
        let rec loop a b =
            seq {
                if a < countA && b < countB then
                    let itemA = itemsA[a]
                    let itemB = itemsB[b]

                    let test = compare itemA itemB
                    if test < 0 then
                        yield A itemA
                        yield! loop (a + 1) b
                    elif test > 0 then
                        yield B itemB
                        yield! loop a (b + 1)
                    else
                        yield AB (itemA, itemB)
                        yield! loop (a + 1) (b + 1) // no warning for this one

                elif a < countA then
                    yield A itemsA[a]
                    yield! loop (a + 1) b

                elif b < countB then
                    yield B itemsB[b]
                    yield! loop a (b + 1)
            }

        member _.Exec () = loop 0 0

    let compareWith compare itemsA itemsB = CompareWith(compare, itemsA, itemsB).Exec()

When I compile, I get warnings for 4 of the yield! loop calls, but not for the one in the else branch. I get these warnings for both Debug and Release.

Bananas-Are-Yellow avatar Aug 02 '25 14:08 Bananas-Are-Yellow

If I change each of those yield! loop calls into if false then () else yield! loop, the warnings are not shown with a Debug build, but they still appear with a Release build.

Bananas-Are-Yellow avatar Aug 02 '25 15:08 Bananas-Are-Yellow

The following version does not produce warnings in either Debug or Release:

module SortedArray =
    type private CompareWith<'a,'b> (compare, itemsA: array<'a>, itemsB: array<'b>) =
        let countA = Array.length itemsA
        let countB = Array.length itemsB

        [<TailCall>]
        let rec loop a b =
            seq {
                match a < countA, b < countB with
                | true, true ->
                    let itemA = itemsA[a]
                    let itemB = itemsB[b]

                    let test = compare itemA itemB
                    if test < 0 then
                        yield A itemA
                        yield! loop (a + 1) b
                    elif test > 0 then
                        yield B itemB
                        yield! loop a (b + 1)
                    else
                        yield AB (itemA, itemB)
                        yield! loop (a + 1) (b + 1)

                | true, false ->
                    yield A itemsA[a]
                    yield! loop (a + 1) b

                | false, true ->
                    yield B itemsB[b]
                    yield! loop a (b + 1)

                | false, false -> ()
            }

        member _.Exec () = loop 0 0

    let compareWith compare itemsA itemsB = CompareWith(compare, itemsA, itemsB).Exec()

So this is a workaround for this case for me. But I have other cases that give false warnings, which are a bit harder to reduce to something concise I can show here.

Bananas-Are-Yellow avatar Aug 03 '25 18:08 Bananas-Are-Yellow

@dawedawe : Do you have an idea of what my going wrong here ?

T-Gro avatar Aug 04 '25 13:08 T-Gro

Here is another case. This is cut down from the actual code, so in this form it does nothing useful and is only a test case for the compiler.

module TailCall =
    type State =
        | Text
        | Backslash
        | Ampersand

    type Expand (chars: seq<char>) =
        let en = chars.GetEnumerator ()

        [<TailCall>]
        let rec loop _state =
            let gobble ch =
                seq {
                    match ch with
                    | '\\' -> yield! loop Backslash
                    | '&' -> yield! loop Ampersand
                    | _ ->
                        yield ch
                        yield! loop Text
                }

            seq {
                if en.MoveNext () then
                    yield! gobble en.Current
            }

In either a Debug or Release build, all 3 yield! loop calls in the gobble function produce the warning:

warning FS3569: The member or function 'loop' has the 'TailCallAttribute' attribute, but is not being used in a tail recursive way.

Bananas-Are-Yellow avatar Aug 04 '25 18:08 Bananas-Are-Yellow

This one is a bit more involved. As before, this is reduced from the actual code, but serves as a test case for the compiler.

type SimpleLazy<'t> (getValue: unit -> 't) =
    let mutable getValue = ValueSome getValue
    let mutable value = Unchecked.defaultof<'t>

    [<DebuggerBrowsable(DebuggerBrowsableState.Never)>]
    member _.Value =
        match getValue with
        | ValueNone -> value
        | ValueSome f ->
            value <- f ()
            getValue <- ValueNone
            value

    member _.IsValueCreated = getValue.IsNone

module TailCall =
    [<NoEquality; NoComparison>]
    type LazyList<'t> =
        | LazyCons of 't * SimpleLazy<LazyList<'t>>
        | LazyEmpty

    type Example<'t> (source: seq<'t>) =
        let en = source.GetEnumerator ()

        [<TailCall>]
        let rec loop () =
            if en.MoveNext () then
                LazyCons (en.Current, SimpleLazy loop) // warning FS3569 here
            else
                en.Dispose ()
                LazyEmpty

In this case, I get the warning at the line commented above, but only in a Release build.

Bananas-Are-Yellow avatar Aug 04 '25 18:08 Bananas-Are-Yellow

I assume [<TailCall>] works according to the RFC-1011 spec, which says:

All uses of an attributed function within its recursive scope would need to be in tail position. Thus passing the function as a higher-order argument would not be allowed. This can potentially limit the utility of such a function.

Is SimpleLazy loop an example of passing the function as a higher-order argument, even though SimpleLazy just stores the function for later use?

Bananas-Are-Yellow avatar Aug 05 '25 08:08 Bananas-Are-Yellow

Is SimpleLazy loop an example of passing the function as a higher-order argument, even though SimpleLazy just stores the function for later use?

Well it is passed to the constructor. The function is not really recursive at all, as it is not calling itself.

majocha avatar Aug 05 '25 08:08 majocha

Here is a much simpler version of the SimpleLazy code, which shows the problem with a Debug or Release build.

module TailCall =
    type Wrapper (create: unit -> Wrapper) =
        member _.Create = create

    [<TailCall>]
    let rec loop () =
        Wrapper loop // warning FS3569 here

But this alternative version does not give the warning:

module TailCall =
    type Wrapper = Wrapper of (unit -> Wrapper)

    [<TailCall>]
    let rec loop () =
        Wrapper loop // no warning

In the first example, the constructor might contain code that does things other than simply storing the function, whereas in the second example, this is not possible. But still, I don't understand why this isn't tail recursive.

Bananas-Are-Yellow avatar Aug 05 '25 16:08 Bananas-Are-Yellow

@dawedawe are you the expert in this area?

Above, I've provided 3 examples of when the [<TailCall>] attribute incorrectly (as I see it) warns that the function is not tail-recursive.

I suspect that since most recursive functions are nested functions, and the [<TailCall>] attribute can't be applied to a nested function, people are therefore not using the attribute, so bugs are not found and reported. This is just my hunch.

It occurred to me to use a private type to work around this limitation, and now that I am doing so, I am discovering these bugs.

Bananas-Are-Yellow avatar Aug 10 '25 19:08 Bananas-Are-Yellow

As I still feel responsible for the code, I'll look into it. But for good reasons it's unlikely to happen soon.

dawedawe avatar Aug 13 '25 21:08 dawedawe

Here's another case for you.

type Example () =
    [<TailCall>]
    let rec getZero i =
        match i with
        | 0 -> 0
        | i -> getZero (i - 1)

    [<TailCall>]
    let rec loop x =
        if x < 0 then
            loop -x
        else
            let zero = getZero 123 // warning FS3569 here
            let one = zero + 1
            one

According to the RFC-1011 spec:

All uses of an attributed function within its recursive scope would need to be in tail position.

But the getZero call is not within its recursive scope. It is within the recursive scope of a different function. If you remove the [<TailCall>] attribute from the loop function, the warning goes away.

Interestingly, I don't get the warning if this is a module, rather than a type.

module Example =
    [<TailCall>]
    let rec getZero i =
        match i with
        | 0 -> 0
        | i -> getZero (i - 1)

    [<TailCall>]
    let rec loop x =
        if x < 0 then
            loop -x
        else
            let zero = getZero 123 // no warning
            let one = zero + 1
            one

Bananas-Are-Yellow avatar Aug 14 '25 18:08 Bananas-Are-Yellow

Here is another case. This is cut down from the actual code, so in this form it does nothing useful and is only a test case for the compiler.

module TailCall =
    type State =
        | Text
        | Backslash
        | Ampersand

    type Expand (chars: seq<char>) =
        let en = chars.GetEnumerator ()

        [<TailCall>]
        let rec loop _state =
            let gobble ch =
                seq {
                    match ch with
                    | '\\' -> yield! loop Backslash
                    | '&' -> yield! loop Ampersand
                    | _ ->
                        yield ch
                        yield! loop Text
                }

            seq {
                if en.MoveNext () then
                    yield! gobble en.Current
            }

In either a Debug or Release build, all 3 yield! loop calls in the gobble function produce the warning:

warning FS3569: The member or function 'loop' has the 'TailCallAttribute' attribute, but is not being used in a tail recursive way.

Restructuring the code as follows, avoids the tail call warning in both Debug and Release builds:

module TailCall =
    type State =
        | Text
        | Backslash
        | Ampersand

    type Expand (chars: seq<char>) =
        let en = chars.GetEnumerator ()

        [<TailCall>]
        let rec loop _state =
            seq {
                if en.MoveNext () then
                    yield! gobble en.Current
            }

        and [<TailCall>] gobble ch =
            seq {
                match ch with
                | '\\' -> yield! loop Backslash
                | '&' -> yield! loop Ampersand
                | _ ->
                    yield ch
                    yield! loop Text
            }

Bananas-Are-Yellow avatar Aug 15 '25 17:08 Bananas-Are-Yellow