Struct default value does not default as expected

I have a struct with what should be a default element but it does not default as I expect. What am I not understanding?

import Foundation

struct Item :  Identifiable {
    var value: String
    var icon : String = "πŸšͺ"       // var with default value will NOT work in the map() below
//    var icon : String { "πŸšͺ" }   // computed property WILL work in the map() below
//    let icon : String = "πŸšͺ"       // let with default value WILL work in the map() below

    var id   : String { value }
}
struct Foo : Identifiable {  // same as above but only one member
    var value : String
    var id : String { value }
}

@Observable
class ModelData {
    var item = Item(value: "🐿") // can create without specifying the icon, it defaults
    
    // Section A
    // Create an AoA of Foos using map() and map {}
    var foos  : [[Foo]] = [ ["πŸ˜€", "😴", "😲"].map(Foo.init(value:)) ]  // ?? what is this? ??
    var foos2 : [[Foo]] = [ ["πŸ˜€", "😴", "😲"].map { Foo.init(value: $0) } ]

    // Section B
    // leaving the icon off causes this to fail with error
    //    "Type 'Item' has no member 'init(value:)'"
    //var items : [[Item]] = [["πŸ˜€", "😴", "😲"].map(Item.init(value:) )]
    
    //  Section C
    //  Manually specifying the icon will work
    var boardState : [[Item]] = [
        ["πŸ˜€", "😴", "😲"].map { Item.init(value: $0, icon: "πŸšͺ") },
        ["😈", "β€οΈβ€πŸ©Ή", "πŸ‘‹"].map { Item.init(value: $0, icon: "πŸšͺ") },
        ["😲", "πŸ˜€", "😴"].map { Item.init(value: $0, icon: "πŸšͺ") }
    ]
}

Clearly, a constant (i.e. let) can have an assigned default parameter while a variable must use a computed value.

  1. Why is that? The entire point of a default value is it shouldn't need to be specified or computed.
  2. I understand map { Item... } but I'm not familiar with the map(Item...) construct used in Section A and I don't get how it works. Help?

Note: The Apple documentation for map() comes up well down on the Google results, after half a dozen StackOverflow etc pages. Probably because the link doesn't work and gets a 404.

Hi! With the var stored property version, compiler generates an initializer for you that looks like this:

    init(value: String, icon: String = "πŸšͺ") {
        self.value = value
        self.icon = icon
    }

So when you go to use the map function you can't specify a single value initializer because it doesn't exist. That is the kind that will work with the map(function) form of map.

When you make icon a computed property or constant, the compiler generated initializer then becomes

  init(value: String) {
        self.value = value
    }

This works fine since there is a single argument.

You could work around the issue by writing your own initializer that looks like this:

    init(value: String) {
        self.value = value
        self.icon = "πŸšͺ"
    }

Then the map(Item.init(value:)) will work because there is only one input parameter.

Another cool thing about map is that it works well with keypaths. So you can say, items.map(\.id) to get a list of value strings, for example.

3 Likes

Hi! With the var stored property version, compiler generates an initializer for you that looks like this:

  init(value: String, icon: String = "πŸšͺ") {
        self.value = value
        self.icon = icon
    }

So when you go to use the map function you can't specify a single value initializer because it doesn't exist. That is the kind that will work with the map(function) form of map.

But...but...why? It's an argument with a default! This works fine:

struct zot {
    var x: Int
    var y: String = "foo"
}

print("Value is: ", zot(x: 8))

// Output:
// Value is:  zot(x: 8, y: "foo")

...wait, the above code is from a macOS CLI program while the original code is an iOS app. Is that the problem? tests No, it still chokes on the map(zot(x:)) version

Why??! What is happening?! Why can I use the single-arg version, except I can't if I'm calling it in a map, except I can if I'm using map {} instead of map()?

What is the difference between the two forms of map?

The difference is whether you're calling the function directly or referencing the 'unapplied' version of the function. For better or worse, Swift today only allows using default arguments when calling the function directly, though there's been discussions in the past about lifting that restriction.

One reason why it isn't totally trivial is that default arguments like #line inherit the context of the caller--that is in the function

func f(_ line: Int = #line) { 
  print(line)
}

the value of line will be populated with the line number of the calling function, so we'd have to decide how to handle the following case:

let g = f
g() // prints 1 or 2?
5 Likes

So it's a limitation of the compiler and I'm not crazy. Okay, that's good.

Thank you for the explanation, it helps.

1 Like

For the record, if #line is supposed to be based on the caller then in your example code I would expect it to refer to the g() line. Not sure why it would be anything else in that context. On the other hand, I would also expect #line to refer to the line where that token appears, not to the caller, so what do I know?

1 Like

It's not really a limitation of the compiler: it's a deliberate design choice on the part of the language.

In Swift, a function f(x:y:) is not interchangeable with another function f(x:) irrespective of which or how many of the parameters have default arguments. When you call a function with such defaults, those default values are emitted on the caller sideβ€”as though you (the caller) had written them out. It is important to understand these two interrelated points. Put concretely, given:

// Dynamic library A:
public func f(_: Int, _: Int, _: Int) { }
public func g(_ x: Int, _ y: Int, _ z: Int = 3) { f(x, y, z) }
public func h(_ x: Int, _ y: Int) { f(x, y, 3) }

// Your code:
g(1, 2)
h(1, 2)

...if a later version of dynamic library A changes g so that the default argument to z is 4, your app's existing compiled code exhibits no change in behavior; but if it changes the implementation of h so that it calls f(x, y, 4), your app will change behavior without recompilation.

Adding or removing default arguments is not tantamount to writing out additional overloads of a function with fewer parameters. Your question partly boils down to why you can't refer to g(x:y:z:) as g(x:y:) if z can be defaulted, and the answer is that those are not the same.

When you write map { Foo.init(value: $0) }, the implementation of map calls your closure that takes one argument, and your closure in turn calls Foo.init with two arguments (to both value and icon). When you write map(Foo.init(value:)), the implementation of map would need to call some Foo.init that takes one argument, and there is no such overload.

6 Likes

I think it's a fairly natural interpretation of Foo.init(value:) as sugar for precisely the partial application { Foo.init(value: $0) }. But this would have the potentially unexpected behavior of evaluating default arguments in the context of where the partial application was formed rather then where it ends up being called.

2 Likes