代码

判等

Swifter - Swift 必备 Tips (第三版)

我们在Objective-C时代,通常使用 isEqualToString: 来在已经能确定比较对象和待比较对象都是 NSString 的时候进行字符串判等。Swift中 String 的类型中是没有 isEqualtoString: 或者 isEqual: 这样的方法的,因为这些毕竟是 NSObject 的东西。在Swift 的字符串内容判等,我们简单地使用 == 操作符来进行:

在判等上Swift的行为和Objective-C有着巨大的差别。在Objective-C中 == 这个符号的意思是判断两个对象是否指向同一块内存地址。其实很多时候这并不是我们经常所期望的判等,我们更关心的往往还是对象的内容相同,而这种意义的相等即使两个对象引用的不是同一块内存地址时,也是可以做到的。Objective-C中我们通常通过对 isEqual: 进行重写,或者更进一步去实现类似 isEqualToString: 这样的 isEqualToClass: 的带有类型信息的方法来进行内容判等。如果我们没有在任意子类重写 isEqual: 的话,在调用这个方法时会直接使用 NSObject 中的版本,去直接进行Objective-C的 == 判断。

在Swift中情况大不一样,Swift里的 == 是一个操作符的声明,在 Equatable 里声明了这个操作符的协议方法:

protocol Equatable {
    func == (lhs: Self, rhs: Self) -> Bool
}

实现这个协议的类型需要定义适合自己类型的 == 操作符,如果我们认为两个输入有相等关系的话,就应该返回 true 。实现了 Equatable 的类型就可以使用 == 以及 != 操作符来进行相等判定了(在实现时我们只需要实现 ==!= 的话由标准库自动取反实现)。这和原来Objective-C的 isEqual:的行为十分相似。

比如我们在一个待办事项应用中,从数据库中取得带有使用uuid进行编号的待办条目,在实践中我们一般考虑就使用这个uuid来判定两个条目对象是不是同一条目。让这个表示条目的 TodoItem 类实现 Equatable 协议:

//swift实现判等(NSObject的子类)的两个方法:
//1. 实现Equatable协议,重写 == 运算符
//2. 重写isEqual:方法

//如果在Objective-C中使用这个类的话,只能使用第二种方式,因为Objective-C不支持运算符重载。
//非NSObject的子类只能使用第一种方法。
//swift使用 === 运算符来判断两个AnyObject是否是同一个引用。

class ToDoItem {
    let udid: String
    var title: String

    init(udid: String, title: String) {
        self.udid = udid
        self.title = title
    }


    func isEqual(_ item: ToDoItem) -> Bool {
        return self.udid == item.udid
    }

}

extension ToDoItem: Equatable {

}

func ==(lhs: ToDoItem,rhs: ToDoItem) -> Bool {
    return lhs.udid == rhs.udid
}

对于 == 的实现我们并没有像实现其他一些协议一样将其放在对应的 extension 里,而是放在了全局的scope中。这是合理的做法,因为你应该需要在全局范围内都能使用 ==。事实上,Swift的操作符都是全局的。

Swift的基本类型都重载了自己对应版本的 ==,而对于 NSObject 的子类来说,如果我们使用 == 并且没有对于这个子类的 == 重载的话,将转为调用这个类的 isEqual: 方法。这样如果这个 NSObject子类原来就实现了 isEqual: 的话,直接使用 ==并不会造成它和Swift类型的行为差异; 但是如果无法找到合适的重写的话,这个方法就将回滚到最初的 NSObject 里的实现,对引用对象地址进行直接比较。

因此对于子类的判等你有两种选择,要么重载 ==,要么重写 isEqual:。如果你只在Swift中使用你的类的话,两种方式是等效的;但是如果你还需要在Objective-C中使用这个类的话,因为Objective-C不接受操作符重载,只能使用 isEqual:,这时你应该考虑使用第二种方式。

对于原来Objective-C中使用 == 进行的对象指针的判定,在Swift中提供的是另一个操作符 ===。在Swift中只有一种重载:

func ===(lhs: AnyObject?, rhs: AnyObject?) -> Bool

它用来判断两个是否是同一个引用。

对于判等,和它紧密相关的一个话题就是哈希,重载了判等的话,我们还需要提供一个可靠的哈希算法使得判等的对象在字典中作为key时不会发生奇怪的事情。

哈希

哈希表或者说散列表是程序世界中的一种基础数据结构, 简单说,我们需要为判等结果为相同的对象提供相同的哈希值,以保证在被用作字典的key时的确定性和性能。在这里,我们主要说说在Swift里对于哈希的使用。

判等中我们提到,Swift中对子类对象使用 == 时要是该子类没有实现这个操作符重载的话将回滚到 isequal: 方法。对于哈希计算,Swift也采用了类似的策略。Swift类型中提供了一个叫做 Hashable 的协议,实现这个协议即可为该类型提供哈希支持:

public protocol Hashable : Equatable {
    public var hashValue: Int { get }
}

Swift的原生 Dictionay 中,key 一定是要实现了 Hashable 协议的类型。像 Int 或者 String 这些Swift基础类型,已经实现了这个协议,因此可以用来作为key来使用。比如 IntHashable就是它本身:

let num = 19
print(num.hashValue) // 19

对Objective-C熟悉的读者可能知道 NSObject 中有一个 -hash 方法。当我们对一个的 NSObject 子类的 -isEqual: 进行重写的时候,我们一般也需要将 -hash 方法重写,以提供一个判等为真时返回同样哈希值的方法。在Swift中,NSObject 也默认就实现了 Hashable ,而且和判等的时候情况类似,NSObject 对象的 hashValue 属性的访问将返回其对应的 -hash 的值。

所以在重写哈希方法时候所采用的策略,与判等的时候是类似的: 对于非 NSObject 的类,我们需要遵守 Hashable 并根据 == 操作符的内容给出哈希算法; 而对于 NSObject 子类,需要根据是否需要在Objective-C中访问而选择合适的重写方式,去实现 HashablehashValue 或者直接重写 NSObject-hash 方法。

也就是说,在Objective-C中,对于 NSObject 的子类来说,其实 NSDictionary 的安全性是通过人为来保障的。对于那些重写了判等但是没有重写对应的哈希方法的子类,编译器并不能给出实质性的帮助。而在Swift中,如果你使用非 NSObject 的类型和原生的 Dictionary ,并试图将这个类型作为字典的key的话,编译器将直接抛出错误。从这方面来说,如果我们尽量使用Swift的话,安全性将得到大大增加。

关于哈希值,另一个特别需要提出的是,除非我们正在开发一个哈希散列的数据结构,否则我们不应该直接依赖系统所实现的哈希值来做其他操作。首先哈希的定义是单向的,对于相等的对象或值,我们可以期待它们拥有相同的哈希,但是反过来并不一定成立。其次,某些对象的哈希值有可能随着系统环境或者时间的变化而改变。因此你也不应该依赖于哈希值来构建一些需要确定对象唯一性的功能,在绝大部分情况下,你将会得到错误的结果。

类簇

虽然可能不太被重视,但类簇(class cluster)确实是Cocoa框架中广泛使用的设计模式之一。简单来说类簇就是使用一个统一的公共的类来订制单一的接口,然后在表面之下对应若干个私有类进行实现的方式。这么做最大的好处是避免的公开很多子类造成混乱,一个最典型的例子是 NSNumber 我们有一系列的不同的方法可以从整数,浮点数或者是布尔值来生成一个 NSNumber 对象,而实际上它们可能会是不同的私有子类对象:

NSNumber *num1 = [[NSNumber alloc] initWithInt:1]; //__NSCFNumber
NSNumber *num2 = [[NSNumber alloc] initWithFloat:1.0]; //__NSCFNumber
NSNumber *num3 = [[NSNumber alloc] initWithBool:YES]; //__NSCFBoolean

类簇在子类种类繁多,但是行为相对统一的时候对于简化接口非常有帮助。

在Objective-C中,init 开头的初始化方法虽然打着初始化的名号,但是实际做的事情和其他方法并没有太多不同之处。类簇在Objective-C中实现起来也很自然,在所谓的“初始化方法”中将 self 进行替换,根据调用的方式或者输入的类型,返回合适的私有子类对象就可以了。

但是Swift中的情况有所不同。因为Swift拥有真正的初始化方法,在初始化的时候我们只能得到当前类的实例,并且要完成所有的配置。也就是说对于一个公共类来说,是不可能在初始化方法中返回其子类的信息的。对于Swift中的类簇构建,一种有效的方法是使用工厂方法来进行。例如下面的代码通过的工厂方法将可乐和啤酒两个私有类进行了类簇化:

class Drinking {
    typealias LiquidColor = UIColor
    var color : LiquidColor {
        return .clear
    }

    class func drinking(name: String) -> Drinking {
        var drinking: Drinking
        switch name {
        case "Coke":
            drinking = Coke()
        case "Beer":
            drinking = Beer()
        default:
            drinking = Drinking()
        }
        return drinking
    }

}

class Coke: Drinking {
    override var color: Drinking.LiquidColor {
        return .black
    }
}

class Beer: Drinking {
    override var color: Drinking.LiquidColor {
        return .yellow
    }
}

let coke = Drinking.drinking(name: "Coke")
let beer = Drinking.drinking(name: "Beer")
coke.color // Black
beer.color // Yellow

通过获取对象类型中提到的方法,我们也可以确认 cokebeer 各自的动态类型分别是 CokeBeer

let cokeClass = NSStringFromClass(type(of: coke))
let beerClass = NSStringFromClass(type(of: beer))

print(cokeClass) // Coke
print(beerClass) // Beer

调用 C 动态库

C是程序世界的宝库,在我们面向的设备系统中,也内置了大量的 C 动态库帮助我们完成各种任务。比如涉及到压缩的话我们很可能会借助于 libz.dylib ,而像xml的解析的话一般链接 libxml.dylib 就会方便一些。

因为Objective-C是C的超集,因此在以前我们可以无缝地访问C的内容,只需要指定依赖并且导入头文件就可以了。但是骄傲的Swift的目的之一就是甩开C的历史包袱,所以现在在Swift中 直接使用C代码或者C的库是不可能的。举个例子,计算某个字符串的 MD5 这样简单地需求, 在以前我们直接使用 CommonCrypto 中的 CC_MD5 就可以了,但是现在因为我们在Swift中无法直接写 #import <CommonCrypto/CommonCrypto.h> 这样的代码,这些动态库暂时也没有module化,因此快捷的方法就只有借助于通过Objective-C来进行调用了。因为Swift是可以通过 {product-module- name}-Bridging-Header.h 来调用Objective-C代码的,于是C作为Objective-C的子集,自然也一并被解决了。比如对于上面提到的 MD5的例子,我们就可以通过头文件导入以及添加 extension 来解决:

//Tips25_判等-Bridging-Header.h
#import <CommonCrypto/CommonCrypto.h>

// StringMD5.swift
extension String {
    var MD5: String {
        var digest = [UInt8](repeatElement(0, count: Int(CC_MD5_DIGEST_LENGTH)))
        if let data = data(using: .utf8) {
            data.withUnsafeBytes({ (bytes: UnsafePointer<UInt8>) -> Void in
                CC_MD5(bytes, CC_LONG(data.count), &digest)
            })
        }

        var digestHex = ""
        for index in 0..<Int(CC_MD5_DIGEST_LENGTH) {
            digestHex += String(format: "%02x", digest[index])
        }
        return digestHex
    }
}

//测试
print("swifter.tips".MD5)
// dff88de99ff03d109de22fed4f71a273

当然,那些有强迫症的处女座读者可能不会希望在代码中沾上哪怕一点点C的东西,而更愿意面对纯纯的Swift代码,这样的话,也不妨重新制作Swift版本的轮子。比如对于CommonCrypto 里的功能,已经可以在这里找到完整的Swift实现了,如果你只是需要MD5的话,这里也有一个实现。不过如果可能的话,暂时还是建议尽量使用现有的经过无数时间考验的C库。一方面现在Swift还很年轻,各种第三方库的引入和依赖机制还并不是很成熟; 另外,使用动态库毕竟至少可以减少一些app尺寸,不是么?

C 代码调用和 @asmname

如果我们导入了的C库的话,我们就可以在Swift中无缝地使用中定义的C函数 了。它们涵盖了绝大多数C标准库中的内容,可以说为程序设计提供了丰富的工具和基础。导入 Darwin 十分简单,只需要加上 import Darwin 即可 。但事实上,Foundation 框架中包含了 Darwin 的导入,而我们在开发app时肯定会使用 UIKit 或者 Cocoa 这样的框架,它们又导入了 Foundation ,因此我们在平时开发时并不需要特别做什么,就可以使用这些标准的C函数了。很让人开心的一件事情是Swift在导入时为我们将也进行了类型的自动转换对应,比如对于三角函数的计算输入和返回都是Swift的 Double类型,而非C的类型:

func sin(_ x: Double) -> Double

使用起来也很简单,因为这些函数都是定义在全局的,所以只需要直接调用就可以了:

sin(M_PI_2) // 1.0

对于第三方的C代码,Swift也提供了协同使用的方法。我们知道,Swift中调用Objective-C代码非常简单,只需要将合适的头文件暴露在 {product-module- name}-Bridging-Header.h文件中就行 了。而如果我们想要调用非标准库的C代码的话,可以遵循同样的方式,将C代码的头文件在桥接的头文件中进行导入:

//test.h
int test(int a);

//test.c
int test(int a) {
    return a+1;
}

//Module-Bridging-Header.h
#import "test.h"

//File.swift
func testSwift(input: Int32) {
    let result = test(input)
    print(result)
}

testSwift(1) //2

另外,我们甚至还有一种不需要借助头文件和来导入C函数的方法,那就是使用Swift中的一个隐藏的符号 @asmname@asmname可以通过方法名字将某个C函数直接映射为Swift中的函数。比如上面的例子,我们可以将 test.hModule-Bridging-Header.h 都删掉,然后将swift文件中改为下面这样,也是可以正常进行使用的:

//test.c
int test(int a) {
    return a+1;
}

//File.swift
// 将 C 中的 test 方法映射为 Swift 的 c_test 方法
@asmname("test") func c_test(a: Int32) -> Int32

func testSwift(input: Int32) {
    let result = c_test(input)
    print(result)
}

testSwift(1) //2

这种导入在第三方C方法与系统库重名导致调用发生命名冲突时,可以用来为其中之一的函数重新命名以解决问题。当然我们也可以利用Module名字+方法名字的方式来解决这个问题。

除了作为非头文件方式的导入之外,@asmname 还承担着和 @objc 的“重命名Swift中类和方法名 字”类似的任务,这可以将C中不认可的Swift程序元素字符重命名为ascii码,以便在C中使用。

输出格式化

在Swift里,我们在输出时一般使用的 print 中是支持字符串插值的,而字符串插值时将直接使用类型的 StreamablePrintable 或者 DebugPrintable 协议(按照先后次序,前面的没有实现的话则使用后面的)中的方法返回的字符串并进行打印。这样,我们就可以不借助于占位符,也不用再去记忆类型所对应的字符表示,就能很简单地输出各种类型的字符串描述了。比如:

let a = 3
let b = 1.3445
let c  = "Hello"
print("int: \(a) double: \(b) string: \(c)")
//int: 3 double: 1.3445 string: Hello

Swift 并不支持在字符串插值时使用像小数点限定这样的格式化方法。因此,我们可能不得不往回求助于使用类似原来那样的字符串格式化方法。String 的格式化初始方法可以帮助我们利用格式化的字符串:

let format =   String(format: "%.2f", b)
print(format)//1.34

当然,每次这么写的话也很麻烦。如果我们需要大量使用类似的字符串格式化功能的话,我们最好为 Double 写一个扩展:

extension Double {
    func format(_ f: String) -> String {
        return String(format: "%\(f)f", self)
    }
}

这样, 在使用字符串插值和 print 的时候就能更方便一些了:

let f = ".2"
print("double: \(b.format(f))")

results matching ""

    No results matching ""