What's the new way to explain the design of function/method APIs?

Rather than add to the tide of complaints about SE-0279, I'll ask a question that I hope will be more productive. Before we got multiple trailing closures, there was a simple way to explain the general form most well-designed imperative method signatures should exhibit at the call site.

muncher.chew(                                      // 1
  leaves, having: .jaggedEdges, where: isRed,      // 2
  trapOnError: true, lighting: sunLight)           // 3
{ ... }                                            // 4
  1. The subject and verb of a roughly grammatical English clause
  2. The rest of the clause
  3. Additional arguments that don't fit grammatically, often incidental to the call and defaulted
  4. Trailing closure, the primary parameterized act of the call

Any of parts 2-4 may be empty. Non-imperative calls follow an almost identical pattern, with parts 1 and 2 forming a noun phrase.

The question: what's the new simple explanation?

Thanks,
Dave

4 Likes

What do you think about:

  1. The subject and verb of a roughly grammatical English clause
  2. The rest of the clause
  3. Additional arguments that don't fit grammatically, often incidental to the call and defaulted
  4. Trailing closure, the primary parameterized act of the call
  5. Additional trailing closures that don't fit grammatically, often incidental to the call and defaulted
1 Like

I think that makes existing APIs that match the old pattern, like the one above, not match the new pattern (isRed might have been a closure). Some questions come up for me:

  1. Do we have to retroactively declare that a poor API now?
  2. Do we have to declare that an example of poor usage now?
  3. Regardless of the answer to 1, is this explanation going to work in practice for new APIs?
  4. Is there no simple explanation that works for both existing and legacy code?

this makes old APIs retroactively “bad”, but for me at least @kylemacomber ’s explanation makes the most sense because i have always treated the spelling of trailing closures as if they were statement blocks (like defer), my favorite being the “with” pattern:

resource.with 
{
   ...
}

with multiple trailing closure blocks, i would be inclined to treat them as if they were any other multiple-block construct in the language (if-else, do-catch-catch, etc)

muncher.chew(leaves, having: .jaggedEdges)
{
    ...
}
where: 
{
    ...
}

the primary action goes in the first block, and secondary actions come after it each with their own labels. so yes, it won’t work for legacy code, but it’s not like APIs shouldn’t evolve, or that the old APIs were 100% perfect and therefore any logical explanation will naturally include them.

that being said i don’t know if the “muncher” example is the best example because here you are using the leaf color closure as a predicate, and those ought to come before the action, just like the predicate of an if statement comes before the body. a better example might be a failure handler like

muncher.chew(leaves, having: .jaggedEdges)
{
    // edible leaves
}
else:
{
    // inedible leaves
}

This seems dangerous to me though. For example: suppose there is a return statement inside the else {} block. Now it’s no longer obvious what scope you’re in so it would be easy to get confused what you’re returning from, especially if there’s a nearby if-else that also has a return statement. Is the colon after the else really a big enough clues that we’re not using normal control flow? I think my preference would be to make this look as different from control flow operations as possible.

Edit: quoting code blocks on mobile is hard :roll_eyes:

? That similarity with “if” seems like it would make it a good/relevant example.

It doesn't seem to me like your example needs to change in light of SE-0279. AFAICT there has always been a tension between the API guideline to strive for fluent usage:

  • Prefer method and function names that make use sites form grammatical English phrases

And the implicit guideline to:

  • Prefer to locate a parameter with function type at the end of the parameter list.

With this API you made the choice to prioritize fluent usage, I don't see why SE-0279 would change this tradeoff.

I think so. I think these rules are more natural because locating additional, defaulted trailing closures at the end is more consistent with other API guidelines:

  • Prefer to locate parameters with defaults toward the end of the parameter list. Parameters without defaults are usually more essential to the semantics of a method, and provide a stable initial pattern of use where methods are invoked.

It's my impression from auditing the iOS SDK that there was already an inconsistency prior to SE-0279, probably because no explicit API guidelines exist for trailing closures.

You have APIs that locate additional, defaulted trailing closure at the end, like UIView.animate(withDuration:animations:completion:):

UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
}

UIView.animate(withDuration: 0.3) {
  self.view.alpha = 0
} completion: { _ in
  self.view.removeFromSuperview()
}

... and those that don't, like Combine's sink(receiveCompletion:receiveValue:):

ipAddressPublisher
  .sink { identity in
    self.hostnames.insert(identity.hostname!)
  }

ipAddressPublisher
  .sink(receiveCompletion: { completion in
    // handle error
  }) { identity in
    self.hostnames.insert(identity.hostname!)
  }

I think SE-0279 makes it natural to locate additional, defaulted trailing closures at the end of the parameter list, and we should consistently embrace that direction moving forward.

Thanks for your reply, Kyle!

I don't see that I had to make a choice. I did locate a parameter with function type at the end of the parameter list. That's what { ... } is. There's also substantial fluency.

Let me ask the question differently. Presumably SE-0279 doesn't let you use trailing closure syntax for the where: parameter in my example because of the non-closure parameters that come after it(?). But now let's say those parameters are defaulted, or just not present in the API:

muncher.chew(leaves, having: .jaggedEdges, where: { !$0.isRed }) {
  ... 
}

With SE-0279, something like this becomes possible, right?

muncher.chew(leaves, having: .jaggedEdges) { !$0.isRed } 
masticationCallback: { ... }

Is that now to be considered the preferred way to write the call because it uses all possible trailing closures, even though the predicate now occupies the “primary closure” position?

Alternatively, is the call to be considered poorly designed because it doesn't read well when used that way? The API was designed according to the old pattern, where the last parameter is the primary action. The new pattern says the second-to-last parameter is the primary action.

I suppose that the old usage could be said to fit both the old pattern and your proposed new pattern, with the where: parameter falling into parts 1-3. So If we can additionally find a way not to declare the old API design wrong/suboptimal, that would address my concern.

I don't see how defaults are relevant here. There's not necessarily a default for the where: parameter in my example.

I'm sure there are lots of suboptimal/inconsistent Swift APIs in the SDKs; we did an automated conversion and since then there have been some revisions, but it's a massive collection and there was limited time/resources. When I say “legacy code” I mean APIs that were designed for Swift, explicitly intending to support the old pattern.

1 Like

These questions lead to three rather uncomfortable places:

First, there is no reasonable way for the author of a function (that happens to have closure parameters that could trail) to communicate the author's intent as to what conventions ought to be used when a user calls the function.

Second, authors of existing functions are mildly compelled to examine and rename those functions to take into account the new permutations of how those functions might be called.

Third, with this Message from the Core Team hanging in the air, it is not at all clear whether a trailing-closure-related naming choice made, today, will be valid, tomorrow.

2 Likes