Strange behavior of #line and #file stringfication

It is common pattern to add #line or #file as function's default parameter, so that in function you can get caller information.

// for example, a.swift line 1 here
func foo(line: Int = #line, file: String = #file) {
    print("caller", file, line)
}

// for example, b.swift line 1 here
foo()  // caller b.swift 2

However, when I use this in tuple, the behavior becomes different...

// for example, a.swift line 1 here
func bar(caller: (String, Int) = (#file, #line)) {
    print("caller", caller)
}

// for example, b.swift line 1 here
bar()  // caller a.swift 2

I cannot understand what's happening here. Why the bar yields declared place, instead of caller place?

3 Likes

I cannot explain it, but it seems that when the default argument value is an expression (and not just the plain directive) then it is evaluated in the context of the called function, and not in the context of the caller:

import Foundation

func foo(line : Int = #line, function: String = #function) {
    print(line, function)
}
// The next line is line #7:
foo() // 7 testprog

// The next line is line #10:
func bar(line : Int = #line + 0, function: String = #function + "") {
    print(line, function)
}

bar() // 10 bar(line:function:)
1 Like

It's interesting. And just using (#line) instead of #line makes behavior different.

func foo(line: Int = #line) {
    print("caller", file, line)
}

func bar(line: Int = (#line)) {
    print("caller", file, line)
}

Same observation. Looks like this is by design.

1 Like

Ah, I haven't noticed that. But is there any source of this design? I feel it's buggy.

2 Likes

I am with you. Just playing devil's advocate, here's what the doc has to say on this:

Note that one way of reading this statement is that it specifically describing using a special literal (like #file or #line) as a default value of a function of method parameter while it doesn't specify what would happen if you use a non literal expression like #file + "" in this context, which is a non literal expression for the same reason this is not:

enum E: String {
    case foo = "foo" + "" //🛑 Raw value for enum case must be a literal
}

Having said that, I agree this behaviour is inconvenient and feels like bug.

1 Like

It's a great issue to raise, and very nice to show the docs on point. It's a problem I've struggled with in writing assertion utilities.

I'd support a proposal for Swift to extend the special-literal special case to include tuples made exclusively of special literals, or to just define the composite of these special literals as itself a special literal (with a type that can be propagated). (Not sure if this will be feasible via macros...)

When writing assertion utilities for testing, to get tracing back to the call-site, each layer of functions needs to include all the default arguments (minimally #file and #line) - API pollution.

The pollution can be contained by explicit wrapping into some kind of SourceLocation struct (and then circumvented with a thread-local), but this doesn't have the nice ergonomics of the default argument, where API users can be oblivious and it all Just Works.

The type is necessary when you declare a whole series of test cases and then run them through a common harness. The harness assertion call site is useless; the error needs to point back to the test case declaration site. To do that requires some instance with a type that can be passed down the chain (or stuffed in a thread-local).

+1

at the calling convention level, swift already always eagerly destructures tuple parameters, so arguably this would not be a special case, it would actually be making the language more consistent with how it works at a lower level.

Indeed. Any idea how to minimise this pollution? Example:

typealias SourceLocation = (String, Int, String)
typealias CallStack = [SourceLocation]

func foo(_ callStack: CallStack = [], file: String = #fileID, line: Int = #line, function: String = #function) {
    bar(callStack.appending((file, line, function)))
}

func bar(_ callStack: CallStack = [], file: String = #fileID, line: Int = #line, function: String = #function) {
    baz(callStack.appending((file, line, function)))
}

func baz(_ callStack: CallStack = [], file: String = #fileID, line: Int = #line, function: String = #function) {
    let callStack = callStack.appending((file, line, function))
    if Bool.random() { // simulate bad condition
        print(callStack.map{"\($0.0):\($0.1) in \($0.2)"}.joined(separator: "\n"))
        fatalError()
    }
}

extension Array {
    func appending(_ element: Element) -> Self {
        var array = self
        array.append(element)
        return array
    }
}

func main() {
    foo()
}

main()

Produces the following desired call stack:

App/main.swift:40 in main()
App/main.swift:16 in foo(_:file:line:function:)
App/main.swift:20 in bar(_:file:line:function:)
App/main.swift:27: Fatal error
2023-03-14 00:13:24.333442+0000 App[8410:42862888] App/main.swift:27: Fatal error