I have quite a unique problem, and I am canvassing the Swift community in support of a solution.
At my workplace, we have a large amount of Swift. Our apps have several million lines of Swift powering them. We have large numbers of engineers writing Swift too; around 200+ contribute to our iOS codebase. We have so much Swift, that we wonder if we must have the largest Swift codebase around. In talking with other Large Silicon Valley Companies™️, we think we might have the most (and if you're reading this and you have more Swift LoC, I'd love to chat with you to trade notes!)
We also deprecate our oldest supported iOS version every year. We do this around August, because September usually brings a new iOS version to support, and we only want to support three major iOS versions at any given time.
However, because we have lots of Swift, we have one major problem every year when we try to do this which is getting worse and worse every time we do it. Setting the Deployment Target to a newer major version of iOS causes thousands of deprecation–related warnings to appear in our build systems (Xcode, buck, and xcodebuild
).
At my employer, we don't like having thousands of warnings cluttering Xcode, so we enable -Werror
for our Objective-C and -warnings-as-errors
for our Swift. This works well, because engineers writing code always write code that does not surface warnings, and while they are working if there are any new errors or warnings from the compiler then they can easily see them because there aren't thousands of other warnings they didn't create to sift through.
Today, Swift does not provide a way to suppress warnings at the source where those warnings are emitted. But in Objective-C, you could do this:
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
[[UIApplication sharedApplication] setStatusBarStyle:UIStatusBarStyleLightContent animated:YES];
#pragma clang diagnostic pop
The warning that is emitted by -Wdeprecated-declarations
comes from a deprecation inside Apple's SDK. So if we wrote code that called a deprecated Apple API, we have these options available to us when the warning appears:
- Rewrite the code to not emit a warning.
- Change the code to call an Objective-C wrapper of Apple's API — the wrapper itself uses the
#pragma
directives to make sure no warnings are emitted. - Bifurcate our code into two paths; one path that uses the newer API (the one that Apple is telling you to use in the deprecation warning), and one path that uses the older API — by using the
@available
attribute which means different code paths will be taken at runtime depending on your iOS version. - Disable specific deprecation flags using the
-Wno-
prefix; e.g.,-Wno-deprecated-declarations
, and so on. - Disable
-warnings-as-errors
.
Let me explain why each of these isn't ideal.
1 is obviously the perfect and the ideal solution to the problem. In smaller codebases, this makes a lot of sense. However, our codebase is large. If we have thousands of these warnings, we have to ask many people to take time out of their day to rewrite old code. Because we are risk averse, we will have to wrap the changes inside feature flags (we release our apps on a fixed cadence, e.g., weekly), to make sure that once we roll out the new code if it doesn't work we can still revert to the old code. Materially changing the code has risks, and at least with Objective-C we can look at the warning emitted by Apple and make an informed decision about either changing the code or suppressing the warning. For a lot of the deprecations, we believe that the existing code should continue to work, and if it does not in a future beta of iOS hopefully Apple will notice this too and completely delete it before releasing the new iOS SDK. This doesn't always hold true (and I admit it comes with its own risks), but it is the option we take to minimize issues in production.
2 basically is the approach we follow, taking into account what I said in 1. If we look at a deprecation warning and we decide that we shouldn't risk materially rewriting the code to use a newer API, we write a wrapper in Objective-C of the Apple API. I'm sure you can imagine all the reasons why this isn't great; the most obvious being that we have to duplicate the headerdoc that Apple wrote in the wrapper so that it is surfaced in Xcode, but also that this just adds another hop for our developers when debugging code.
3 is the one I've tried to make work the most, but we have only applied this strategy sparingly for simple cases — the more complex the code, the less likely this can be done easily. But more importantly, this strategy means that you take different paths at runtime based on your iOS version. Obviously as developers we do this all the time in our day–to–day development but that's usually not a big deal if you're writing a new feature and you need to make sure it works with an older Apple API as well as a new one. In this case, you as the developer would know that you support versions x
through to y
of iOS, therefore if you have to create two code paths using @available
you now have to test your code on both paths. But imagine you want to do this to existing code. Just like 1, you have materially changed your code, so you are taking on some risk. You first have to figure out how to get to this code in order to test it. Either you tap away in your app until the code is triggered, or you do some LLDB trickery to run it from the debugger.
4 does not stop engineers from adding new warnings to the codebase for the flags that are disabled. We still want developers to use newer Apple APIs. If we did this, someone could use an API that was deprecated in iOS 5, when our deployment target is currently 9.
5 is the one I consider every year but I can't yet find a way to make this work. I have sometimes pondered if it would be an okay strategy to change the Deployment Target and to disable -warnings-as-errors
. But that means that developers will now be able to introduce new warnings into the codebase. Ideally, we could gate potential Pull Requests by enabling -warnings-as-errors
in our Continuous Integration but of course because there will be existing warnings in other parts of the codebase, developers would basically always be blocked. I've tried to concoct potential workarounds to this problem but none work in the current setup. One such workaround could be to keep a white list of known warnings in our codebase and to ignore them when builds happen on CI but they would still appear in Xcode. We could possibly make Xcode call our own tools in lieu of Xcode's build system and then drop the warnings before they appear in Xcode, but we want to use the Xcode build system with zero modifications where possible.
So that brings us to proposing a change in Swift. I have wondered about this publicly before (apologies in advance for the crass nature of my tweets):
I feel like I don't really understand the argument against having the ability to suppress warnings in Swift code. I would love to hear some of them from you, the community.
I am very impressed by the way this works in Kotlin, and I would like to propose that if this is resolved in a future Swift evolution proposal, we should take inspiration from their approach.
In Kotlin, you can suppress a given warning on a per–line basis like this:
@Suppress("DEPRECATION") someObject.aDeprecatedFunction()
But you can also do it for an entire function:
@Suppress("DEPRECATION")
fun foo(a: Int, b: Int) {
someObject.aDeprecatedFunction()
}
A whole class:
@Suppress("DEPRECATION")
class Baz {
fun foo(a: Int, b: Int) {
someObject.aDeprecatedFunction()
}
}
Or even a whole file:
@file:@Suppress("DEPRECATION")
Kotlin also has some nice ways to deprecate syntax but that's outside the scope of this proposal.
I'm also sure I might be missing a strategy for this problem. If you have the same problem we do but have a solution that works, I'd love to hear it!