Swift 2.2 doesn’t permit default arguments in protocol declarations. This is a problem if you want to abstract your app’s logging code with a protocol because default arguments are used to pass the source location to the log function. However, you can use default arguments in protocol extensions, and that enables a workaround.
A typical log message should include the source location (filename, line number, and possibly function name) of the log event. Swift provides the #file
, #line
, #column
, and #function
debug identifiers for this purpose. At compile time, the parser will expand these placeholders to string or integer literals that describe the current source location. Since it would be incredibly repetitive if we had to include these arguments in every single call of a log function, they are usually passed as default arguments. This works because the compiler is smart enough to expand the debug identifiers to the call site location when they are evaluated in a default argument list. Take the assert
function from the standard library as an example. It is declared like this:
func assert(
@autoclosure condition: () -> Bool,
@autoclosure _ message: () -> String = default,
file: StaticString = #file,
line: UInt = #line)
The third and fourth arguments expand to the caller’s source location by default. (If you’re wondering about the @autoclosure
attribute, it wraps an expression in a closure, effectively delaying the evaluation of the expression from the call site to the function body without requiring the caller to use an explicit closure expression. assert
uses it to perform the evaluation of the condition (which could potentially be expensive or have side effects) only in debug builds, and the evaluation of the message only if the assertion fails.)
A simple, global log function
You can use the same approach to write a log
function that takes a log message and a log level. Its interface and implementation could look like this:
enum LogLevel: Int {
case verbose = 1
case debug = 2
case info = 3
case warning = 4
case error = 5
}
func log(
logLevel: LogLevel,
@autoclosure _ message: () -> String,
file: StaticString = #file,
line: Int = #line,
function: StaticString = #function)
{
// Use `print` for logging.
// We don’t care about `logLevel` at the moment.
print("\(logLevel) – \(file):\(line) – \(function) – \(message())")
}
You could argue either way whether message
should be an @autoclosure
here. The attribute provides no benefit in this simple example because the message is evaluated in any case. We will change that in the next step, however.
A concrete type
Instead of the global log
function, let’s create a type named PrintLogger
that is initialized with a minimum log level. It will only log events whose log level is at least as severe as the minimum. LogLevel
needs to be Comparable
for this, which is why I declared it to store an Int
raw value above:
extension LogLevel: Comparable {}
func <(lhs: LogLevel, rhs: LogLevel) -> Bool {
return lhs.rawValue < rhs.rawValue
}
struct PrintLogger {
let minimumLogLevel: LogLevel
func log(
logLevel: LogLevel,
@autoclosure _ message: () -> String,
file: StaticString = #file,
line: Int = #line,
function: StaticString = #function)
{
if logLevel >= minimumLogLevel {
print("\(logLevel) – \(file):\(line) – \(function) – \(message())")
}
}
}
You would use PrintLogger
like this:
let logger = PrintLogger(
minimumLogLevel: .warning)
logger.log(.error, "This is an error log")
// gets logged
logger.log(.debug, "This is a debug log")
// does nothing
A protocol with default arguments
Next, I’d like to create a Logger
protocol as an abstraction for PrintLogger
. This would allow me to later replace the simple logging using print
statements with a more sophisticated implementation that logs to a file or sends logs to a server. However, I hit a roadblock here because Swift doesn’t allow default arguments in protocol declarations. This does not compile:
protocol Logger {
func log(
logLevel: LogLevel,
@autoclosure _ message: () -> String,
file: StaticString = #file,
line: Int = #line,
function: StaticString = #function)
// error: Default argument not permitted
// in a protocol method
}
So I would have to omit the default arguments to make the protocol compile. That doesn’t seem to be a problem at first. PrintLogger
can adopt the protocol with an empty extension — its existing implementation already fulfills the requirements. And using the logger through a variable of type logger: PrintLogger
works as before.
The problem becomes apparent if you try to use the logger through a variable of the protocol type, logger2: Logger
, as you would in code that is not supposed to know about the concrete implementation:
let logger2: Logger = PrintLogger(
minimumLogLevel: .warning)
logger2.log(.error, "An error occurred")
// error: Missing argument in call
logger2.log(.error, "An error occurred",
file: #file, line: #line, function: #function)
// works but 😱
logger2
only knows about a log
method with five required arguments, so you would have to specify all of them every single time. Yuck!
Move the default arguments into a protocol extension
The solution is to declare two versions of the log
method: one, with no default arguments, in the protocol declaration, as before. I named this method writeLogEntry
. Two, in a protocol extension on Logger
, this time including the default arguments (which is permitted). I kept the name log
for this method because it is supposed to be the public interface of the protocol.
Now, the implementation of log
consists of a single line: it calls through to writeLogEntry
, passing all of its arguments and thereby the source location it received from its caller through the default arguments. writeLogEntry
on the other hand is the method that adopters of the protocol must implement to perform the actual logging. This is the complete protocol:
protocol Logger {
/// Writes a log entry. Types that
/// conform to `Logger` must implement
/// this to perform their work.
///
/// - Important: Clients of `Logger`
/// should never call this method.
/// Always call `log(_:,_:)`.
func writeLogEntry(
logLevel: LogLevel,
@autoclosure _ message: () -> String,
file: StaticString,
line: Int,
function: StaticString)
}
extension Logger {
/// The public API for `Logger`. Calls
/// `writeLogEntry(_:,_:,file:,line:,function:)`.
func log(
logLevel: LogLevel,
@autoclosure _ message: () -> String,
file: StaticString = #file,
line: Int = #line,
function: StaticString = #function)
{
writeLogEntry(logLevel, message,
file: file, line: line,
function: function)
}
}
In the parlance of session 408, writeLogEntry
is a protocol requirement and hence a customization point for the protocol, whereas log
is not backed by a requirement and statically dispatched. This is what we want. The only task of the log
method is to immediately forward to writeLogEntry
, which contains the actual logic. Types that implement Logger
have no reason to override log
.
This is the complete PrintLogger
type, adopting the protocol:
struct PrintLogger {
let minimumLogLevel: LogLevel
}
extension PrintLogger: Logger {
func writeLogEntry(
logLevel: LogLevel,
@autoclosure _ message: () -> String,
file: StaticString,
line: Int,
function: StaticString)
{
if logLevel >= minimumLogLevel {
print("\(logLevel) – \(file):\(line) – \(function) – \(message())")
}
}
}
Now you can use the protocol as expected:
let logger3: Logger = PrintLogger(
minimumLogLevel: .verbose)
logger3.log(.error, "An error occurred") // 🎉
API visibility to clients
One downside of this approach is that it is not easily possible to clearly indicate the purpose of the log
and writeLogEntry
to users of the protocol through access control. Ideally, clients using the protocol should not see the writeLogEntry
method, whereas adopters may see both log
and writeLogEntry
. You can only model this with public
, internal
, and private
if you don’t want to give clients the possibility to create their own types that adopt Logger
. The alternative is to rely on documentation comments.