Attention conservation notice: long post, sorry.
I want to take a step back for a moment and talk about a more general principle that's behind this and many evolution proposals, which is the pursuit of an overarching goal of Swift as a low-ceremony language. As a way to illustrate this I want to use a very short snippet of Objective-C (which was recently posted by @Dimillian on Blue Sky).
- (BOOL)hasSupplementaryViewAfter {
NSInteger cellIndex = [self.elements indexOfObjectPassingTest:^BOOL(MSCListItemElement * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
return obj.elementCategory == UICollectionElementCategoryCell;
}];
return [self.elements indexOfObjectPassingTest:^BOOL(MSCListItemElement * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
return idx › cellIndex && obj .elementCategory == UICollectionElementCategorySupplementaryView;
}] != NSNotFound;
}
In just this small snippet of code there are maybe a dozen different kinds of "ceremony" that Swift avoids. In most of these cases, it does so by trading off what you might consider clarity or safety. To run through them:
- Swift defaults members to be methods rather than statics, so the leading
-
sigil is unnecessary. This is the natural default, without having much of a clarity downside. - Type inference means you do not need to declare the type assigned from
indexOfObjectPassingTest
. This is less clear – you cannot just by reading the code tell that this method returnsNSInteger
(in fact in Swift, it would be anOptional
– let's come back to that). - Explicit
self
. The concerns about Swift not requiring that have been thoroughly litigated. - The verbose name of
indexOfObjectPassingTest
. In Swift the API guidelines discourage needless words. In this case Swift's equivalent isfirstIndex(where:)
, since the call site's use of a closure makes it clear there is a test being applied. Indeed, with trailing closure syntax, even thewhere
can be omitted. - Type inference appears again, in the most extreme case of the types of the block. Swift allows you to name the arguments in a closure without typing them, or to use
$0
as a shorthand isntead of naming them. - Within that type signature, we see pointers and nullability. At first this might not be seen as a clarity trade-off, except that a C++ programmer might not agree. Swift values are passed into functions by const ref by default, but this is not explicit in the code unlike in C/C++/Objective-C.
- And Swift's closure syntax is simpler in other ways, requiring only an opening brace, no
^
or parens. Another possible source of confusion since it makes closures (by design) look like control flow syntax. - Within the first block we get to the subject of this pitch: the first
return
In Swift this first block is a single-expression closure, so thereturn
can be omitted. This thread includes many reasons why this can be considered less clear, confusing for beginners etc. - Swift does not require semicolons, of course. Even this seemingly simple thing introduces complexity and ambiguity into the language. Without semicolons, new lines are syntactically significant (i.e. you can't write on one line
let a = 1 let b = 2
). However, the fact that they don't at first thought seem to be significant – that most of the time you think of Swift dropping the semicolons as just casting off a redundant vestigial tail – is a testament to how well the grammar and parser handles this. But there are surprising edge cases you can hit on rare occasions. - Finally we get to the actual subject of this pitch – the second return. This code is written to first get a value, then use it for the return value. With this proposal, that
return
could be dropped. - At the end, there is a
!= NSNotFound
. In Swift, this would be represented by thenil
case of anOptional
return value. But even here, you could make the case this is less clear. The words "not found" clarify the meaning of the return value. PerhapsfirstIndex(where:)
should define a custom enum, with.found(Index)
and.notFound
cases, instead of re-usingOptional
.
So let's look at the Swift equivalent and talk about that. First, let's take a fairly literal approach, which came via @Paul_Cantrell:
func hasSupplementaryViewAfter() -> Bool {
let cellIndex = elements.firstIndex {
$0.elementCategory == .cell
} ?? .max
return elements.enumerated().contains { idx, obj in
idx > cellIndex && obj.elementCategory == .supplementaryView
}
}
Most people[1] will look at that code and agree that it is vastly better – clearer, more readable, more enjoyable – than the Objective-C code. Now, I am putting a finger on the scales by comparing this code to Objective-C, which is notoriously verbose. But hopefully this helps illustrate how important Swift's low ceremony approach is.
Yet there's no doubt that nearly all of the reduction in verbosity has, item by item, a "this is less clear, this could cause confusion" argument to be made against it. What's more, any one of these small improvements could be dropped, and the code wouldn't be much the worse for it. But if you dropped them all, the code ceases to be better. The changes all combine together to give Swift its low-ceremony feel. Their effects compound.
The challenge with this situation–that to achieve the low-ceremony feel we desire, we must bring together many low-impact things–is that each one has downsides. Individually they have a small impact, so for each one, one might say “we shouldn't do this because ” due to those downsides. For each one, you can say “come on, is that return/semicolon/paren/logner name really a problem? it's NBD, and consider downsides”. Each one is small, and so easy to argue against in isolation, but they add up to be huge.
Another fair counter to this is "OK the benefits multiply, but don't the risks too". But they do not. Firstly, because they are oughtweighed by the fact that ceremony free code is clearer. We'll come back to that point. But also, the cited risks often don't multiply. For example the beginner issue of needing to learn about single-expression closures, or of if let x {
: these are learned once. Whereas you then benefit from them forever. You learn a language once, then use it for years. Over-optimizing for beginners is the wrong trade-off.[2]
Another position to take is "OK fine, where we are now is good, but let's not go further". And to that I also disagree. Swift today is great, but I reject the idea that it doesn't need to be better. There are still places where devs coming from other languages say "huh, ok, but this other language I used to use had ". We should not deny Swift users the benefits other languages have, out of an excess of unjustified caution that Swift needs to be settled, or because downsides can be cited about this latest change that could also be cited about all the other features Swift already has that come together to make it the language it is today.
Ceremony reduction isn't the only direction Swift can go in future. There have been evolution proposals to increase ceremony where it is important. For example, the addition of any
to indicate use of existentials, based on the experience that lack of that ceremony was leading people to do the wrong thing and default to existentials when they probably wanted generics. But overall, the general direction of Swift is to reduce ceremony for generics too – from protocol extensions in Swift 2, to the recent addition of primary associated types.
Let's talk about the word clarity. I think this term tends to get misused, and conflated with verbosity. It's sometimes taken as a given that being more explicit always means being more clear. Let me try and argue it's often the opposite.
I called that first translation "literal" because this code does something to remain similar to the original: it replaces the nil
returned from firstIndex
with Int.max
.
Interestingly, this is one place where the Swift code is more verbose than the Objective-C code. indexOfObjectPassingTest
returns a sentinel value. And it so happens that this sentinel value is defined to be Int.max
, and the code later takes advantage of this: idx > cellIndex
is going to be false for all values when the value returned is NSNotFound
. But this is partly luck – the sentinel value could have been -1
, as it is in a lot of languages and APIs. Swift, by using optionals instead of sentinel values, forces this information out into the code, explicitly.
This is an example where Swift made a choice that is certainly harder for beginners. Sentinal values, for all their faults, are easily explained in a sentence or two. Whereas if you were showing this to a beginner, you may have to take a lengthy pause to explain the entire concept of optionals. But it would be a mistake to think that we should therefore use sentinal values, because they're easier to teach.
Once the optional forces the .max
explicitly into the code, you can then make another leap. This code could be clearer if either it avoided doing that redundant iteration over the whole array in the case of not found or just !
-ed the return value because that test can never fail (e.g. there is always one .cell
). Let's say it's the former. It can be rewritten like this:[3]
func hasSupplementaryViewAfter() -> Bool {
if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell }) {
elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
} else {
false
}
}
This code is an improvement, both in performance (objectively) and clarity (subjectively). And it came about in part because of Swift's use of Optional
forcing the surfacing of the detail about .max
. But also this refactoring was obvious because the rest of the code was so short and clear. You can see all of it at once, because you are not distracted by the huge amounts of ceremony the Objective-C code did. So you spot improvements or bugs more easily.
Clarity is correlated to, but not synonymous with, code being explicit. The Swift code was less explicit in various ways, which made it shorter and easier to read, and therefore clearer.
This isn't always the case. Sometimes brevity is clearer, sometimes verbosity is clearer. let doubled = numbers.map { $0*2 }
is clearer than let doubled = numbers.map { i in i*2 }
. The i in
does nothing but distract. But sometimes the terse $0
syntax is worse. For example, I always name the inputs of reduce
explicitly even in one liners i.e. let sum = numbers.reduce(into: 0) { total, i in total += i }
because I can never keep straight which is $0
and which is $1
.
This shows another important point: you are still responsible for writing clear code. Nearly every technique Swift has for reducing ceremony can be abused, just like $0
above. This pitch will be another example. Just because a feature can be used to write obfuscated code does not been it is a bad feature, just that it can appear in bad programs.
Finally, I'll do a bit more boosting for this pitch. The code above has
if let cellIndex = elements.firstIndex(where: { $0.elementCategory == .cell })
Why did I write it like that? I'll be honest, it's so I could make it a single-expression if
statement and avoid the return. Really, I think it's better to break it onto a separate line:
func hasSupplementaryViewAfter() -> Bool {
let cellIndex = elements.firstIndex { $0.elementCategory == .cell }
if let cellIndex {
return elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
} else {
return false
}
}
That's clearer. But now we have our ceremony back. The return
s here don't add value, they interfere with readability. This pitch would remove them entirely and would be in keeping with Swift's long term vision of being a low ceremony language. I think this is better:
func hasSupplementaryViewAfter() -> Bool {
let cellIndex = elements.firstIndex { $0.elementCategory == .cell }
if let cellIndex {
elements[cellIndex...].contains { $0.elementCategory == .supplementaryView }
} else {
false
}
}
I get that many disagree, that to them the returns are load bearing. But in Swift, returns can be omitted. This isn't something newly proposed here – that is how the language is, today. We just have an exception they can't be omitted when that first declaration is present. And that isn't very well justified.
One more example. Here is some code from the end of a function:
func hit(_ ray: Ray, _ bounds: Range<Double>) -> Hit? {
// snip...
// Find the nearest root that lies in the acceptable range.
var root = (h - sqrtd) / a
if !bounds.surrounds(root) {
root = (h + sqrtd) / a
if !bounds.surrounds(root) {
return nil
}
}
let point = ray.at(root)
return Hit(
at: root,
by: ray,
normal: (point - center) / radius,
)
}
There are two returns
here. One adds immense value. Not just semantically: its presence and highlighting as a keyword draws attention, because it is important. It is an early exit, a place to care about when reading and writing. This is the point in the code where we determine the ray did not hit the object. This is why people like guard
, though it's not a good fit here.[4]
The ending return
has no such role. Functions return when they reach the end. That doesn’t need re-stating, and doing so increases ceremony without increasing clarity.
By no means all. There are still some enthusiasts out there. ↩︎
Note, over-optimizing. We should still pay attention to the learning curve and progressive disclosure and make sure Swift remains an approachable language. It's a trade-off, not an absolute. But (and this is subjective) I just don't believe claims that newcomers to Swift are confused by single-expression returns, once they are properly explained. ↩︎
I would not be surprised to find there's a bug in my refactoring here. That shouldn't detract from the point: logic bugs can happen, of course. By removing the ceremony, we give ourselves more of a chance at spotting them. ↩︎
Lots of Swift devs like to use guard for every early exit, and I get why (it's part of the motivation for this pitch). But I think forcing the inversion of the predicate just to use guard is unnecesarily obfuscatory in some cases including this one. ↩︎