面向协议的日志:给 Swift 协议添加默认参数

483 阅读5分钟
原文链接: swift.gg

作者:Natasha The Robot,原文链接,原文日期:2016-05-01 译者:Channe;校对:walkingway;定稿:CMB

Swift 2.2 不允许在协议声明时提供默认参数。如果你想使用协议抽象出 App 中的日志代码,就会面临一个问题。因为默认参数通常用来将源代码位置传递给日志函数。不过,你可以在协议扩展中使用默认参数,这是一个变通方案。

一个典型的日志消息应该包括日志事件的源代码位置(文件名、行号和可能的函数名)。Swift 为此提供了 #file#line#column#function 调试标识。在编译时,解析器将这些占位符展开为字符串或用来描述当前源代码位置的整数字面量。如果我们在每次调用日志函数时都包含这些参数,那重复的次数太多,所以它们通常都是作为默认参数传递。这里之所以可行是因为编译器足够聪明,能够在评估默认参数列表时将调试标识扩展到函数调用处。标准库中的 assert 函数就是一个例子,它这样声明:

func assert(    @autoclosure condition: () -> Bool,
    @autoclosure _ message: () -> String = default,
    file: StaticString = #file,
    line: UInt = #line)
    

第三个和第四个参数默认扩展为调用者源代码的位置。(如果你对 @autoclosure 属性有疑问,它把一个表达式封装为一个闭包,有效地将表达式的执行从调用处延迟到函数体执行时,即闭包表达式在明确使用时才会执行。assert 只在调试构建时使用它来执行 condition 参数的计算(可能代价高昂或者有副作用),同时只在断言失败时才计算 message 参数。)

一个简单、全局的日志函数

你可以使用同样的方法来写一个日志函数,该函数需要一个日志消息和一个日志级别作为参数。它的接口和实现类似于:

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)
    {
    print("\(logLevel) – \(file):\(line) – \(function) – \(message())")
    }
    

你可能主张使用另一种方法,而不是像这里将 message 参数声明为 @autoclosure。这个属性并没有提供多少好处,因为 message 参数无论什么情况都会计算。既然如此,我们来修改一下。

具体类型

为了代替全局的日志函数,我们创建一种叫做 PrintLogger 的类型,它用最小日志级别初始化,只会记录最小日志级别的事件。LogLevel 因此需要 Comparable 协议,这是为什么我之前把它声明为 Int 型来存储原始数据的原因:

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())")
        }
    }
    }
    

你将会这样使用 PrintLogger

let logger = PrintLogger(
    minimumLogLevel: .warning)
    logger.log(.error, "This is an error log")
logger.log(.debug, "This is a debug log")

带默认参数的协议

下一步,我将会创建一个 Logger 协议作为 PrintLogger 的抽象。它将允许我今后使用更高级的实现替换简单的 print 语句,比如记录日志到文件或者发送日志给服务器。但是,我在这里碰了壁,因为 Swift 不允许在协议声明时提供默认参数。下面的代码无法通过编译:

protocol Logger {
    func log(        logLevel: LogLevel,        @autoclosure _ message: () -> String,
        file: StaticString = #file,
        line: Int = #line,
        function: StaticString = #function)
        }
        

因此,我不得不删掉默认参数,使协议编译能够通过。这似乎并不是一个问题。PrintLogger 可以使用带有空扩展的协议,它目前的实现基本上能满足要求。通过使用一个 logger: PrintLogger 类型的变量和之前的用法没有什么区别。

如果你尝试使用一个 logger2: Logger 协议类型的变量,问题马上就来了,因为你调用代码时是猜不到具体的实现的:

let logger2: Logger = PrintLogger(minimumLogLevel: .warning)
logger2.log(.error, "An error occurred")
logger2.log(.error, "An error occurred", file: #file, line: #line, function: #function)

logger2 只知道这个日志函数有五个必须的参数,所以你不得不每次都全部写上它们。讨厌!

把默认参数移到协议扩展里

解决方法是声明两个版本的日志函数:一,在协议声明时没有默认参数,我命名这个方法为 writeLogEntry。二,在 Logger 的协议扩展里包含默认参数(这是允许的),我保持这个方法名就为 log,因为该方法会是这个协议的公开接口。

现在,log 的实现只有一行代码:调用 writeLogEntry,传入所有参数,而调用者通过默认参数传入了源代码位置。writeLogEntry 从另一方面来说是协议必须实现的适配器方法,用来执行实际的日志操作。这里是完整的协议代码:

protocol Logger {
    func writeLogEntry(        logLevel: LogLevel,        @autoclosure _ message: () -> String,
        file: StaticString,
        line: Int,
        function: StaticString)
        }
extension Logger {
    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)
    }
    }
    

按照 session 408 的说法,writeLogEntry 是一个协议要求和协议的用户自定义点,但 log 并不是。这就是我们想要的。log 方法的唯一任务就是立刻转发给 writeLogEntrywriteLogEntry 包含了实际的逻辑。实现 Logger 协议时就没有理由重写log方法了。

下面是采用协议后的完整 PrintLogger 类型:

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())")
        }
    }
    }
    

现在你可以像期望中那样使用协议了:

let logger3: Logger = PrintLogger(
    minimumLogLevel: .verbose)
    logger3.log(.error, "An error occurred") 
    

调用者的 API 可见度

这个方法有一个弊端,不能简便清晰的通过访问控制给使用者指出协议中的 logwriteLogEntry 的作用。理想情况下,调用者使用协议时不会看到 writeLogEntry 方法,然而部署协议的对象可能同时看到 logwriteLogEntry 。如果你不想让调用者创建自己的 Logger 类型,只能使用 publicinternalprivate。当然,通过文档说明情况也是一个选择。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 swift.gg