Covariance, Contravariance以及Generics在 Swift/OC 中的應用.

蹺腳啖牛肉發表於2018-05-17

初次看到這兩個單詞 Covariance, Contravariance也許很茫然, 先解釋一下這兩個單詞的由來, variance是"型變"的意思, 表示兩個源型別的關係是如何影響它們派生出來的複雜型別的關係.

con + variance 表示"共同變化", 即"協變"; contra + variance 表示"相反變化", 即"逆變"; in + variance 表示"不變", 無論這兩個源型別關係如何都不影響派生出來的複雜型別的關係.

Covariance, Contravariance到底是什麼東西, 怎麼使用呢? 先來看看跟它們關係最為緊密的subtype, supertype.

subtype, supertype

class

subtype, supertype是物件導向開發中最常見的型別關係, 即子型別和父型別. 通常情況下父型別出現的地方都可以用子型別的替換, 舉個最常見的例子: Animal 和 Dog, 為了簡化語法我用 Swift 寫, 使用 OC 的效果一致.

class Animal {
    // animal class
}

class Dog: Animal {
    // dog class
}
複製程式碼

Dog 類是 Animal 類的子類, 所有的 dog 都是 animal, 但不是所有的 animal 都是 dog.

子型別替換父型別, 顯然下面第一句正確, 第二句錯誤.

var dog: Animal = Dog()
var animal: Dog = Animal()
複製程式碼

Swift 在宣告變數時可以不寫型別, 編譯時可以通過型別推斷出來, 如果工程裡面非常多的地方用到型別推斷, 勢必會加重編譯時的負擔, 你會發現在Xcode9.0以下編譯時, SourceKitService佔用 CPU 極高, 提示也會掛掉, 這裡是為了更加鮮明地說明問題.

function / closure / block

function, closure, block 其實都是函式指標型別, 都具備輸入輸出的能力, 源型別的關係影響函式指標型別的關係主要是通過輸入引數和返回值決定的, 在函式指標型別中, 有兩個源型別: 輸入引數型別, 返回值型別, 因為這兩種型別的源型別對函式指標型別的關係影響各不相同, 所以函式指標型別的關係受兩種源型別的共同影響.

先看沒有輸入引數, 只有返回值的方法:

/// function
func getAnimal() -> Animal {
    return Animal()
}

func getDog() -> Dog {
    return Dog()
}

/// closure
var animalGet: () -> Animal = {
    return Animal()
}

var dogGet: () -> Dog = {
    return Dog()
}

var animalGetter: () -> Animal = getDog
animalGetter = dogGet

// error
var dogGetter: () -> Dog = getAnimal 
dogGet = animalGet
複製程式碼

上面的程式碼在 OC 的 block 同樣適用, functionclosure 的使用在工廠方法和抽象工廠方法中很常見, 當沒有輸入引數時, 方法的返回值型別如果是父子關係, 那麼這兩個方法型別(函式指標型別)也是父子關係, 同樣遵守 subtypesupertype 的規則, 可以用子型別替換父型別, 也就是子類能賦值給父類.

如果 function 有輸入引數, 有返回值的情況也是一樣嗎?

/// function
func getAnimal(_ animal: Animal) -> Animal {
    return animal
}

func getDog(_ dog: Dog) -> Dog {
    return dog
}

/// closure
var animalGet: (Animal) -> Animal = { (animal) in
    return animal
}

var dogGet: (Dog) -> Dog = { (dog) in
    return dog
}

/// error
var animalGetter: (Animal) -> Animal = getDog
var dogGetter: (Dog) -> Dog = getAnimal

/// error
animalGetter = dogGet
dogGet = animalGet
複製程式碼

加了輸入引數以後, 輸入引數型別是父子關係, 返回值也是父子關係, 但是它們派生出來的函式指標型別就不是父子關係了. 在實際開發中給 closure / block 賦值是很普遍的做法, UIKitFoundation 框架中也有很多類似的場景, 比如集合型別在標準庫的方法: map, filter, flatMap, compact, forEach等, 都是通過傳入閉包來實現可定製化的任務.

subtype, supertype的關係是建立在子型別完全可以替換父型別的基礎上, 是根據Liskov substitution principle準則來判斷的

It says, in short, that an instance of a subclass can always be substituted for an instance of its superclass.

簡單來講這個規則就是子類物件總是能替換父類物件, 即子類物件能賦值給父類物件.

再看下面的例子, 稍微改一下輸入引數的型別:

var animalGet: (Animal) -> Animal = { (animal) in
    return animal
}

var dogGet: (Animal) -> Dog = { (_) in
    return Dog()
}

/// success
var animalGetter: (Dog) -> Animal = animalGet
var dogGetter: (Dog) -> Animal = getAnimal
複製程式碼

從上面可以看出, 輸入引數型別的父子關係和函式指標型別的父子關係是相反的, 也就是逆向的, 為什麼會這樣呢? 其實利用函式式的思維不難理解, 如果你使用過響應式的一些框架 RAC/RxSwift 就更能想明白, 可以把方法(函式指標型別)想象成"流式"結構, 有輸入輸出, 通過許多方法的輸入輸出就構成了的程式, 資料就在一張由方法鋪成的網狀結構中流動.

f: (A) -> B
上圖 f: (A) -> B 表示方法的輸入輸出, 如果能有一個方法無論怎樣的輸入和輸出都能完全替換它, 那說明這個方法就是 f: (A) -> B 方法型別的子類, 把 f 方法想象成水管, 需要另外一個方法 sub f 方法替換 f 方法後"水流"能正常"流動"而不"阻塞"(理解為不能輸入該型別的引數). 那說明 sub f 的輸入口應該比 A 大, 輸出口可比 B 小, 這樣才能確保"水流"的正常流動, 用 sub f 替換 f 沒有任何影響, 如下:

f: (super A) -> sub B

這就說明了上面的例子, 輸入引數型別是父子關係, 派生出的函式指標型別確是子父關係, 這就是"逆變".

override function / properties

過載在開發中非常常見, 在明確型別的父子關係的情況下, 過載父類的屬性或者方法. 那在過載的方法和屬性的時候是如何確保父子關係不破裂的呢?

來看下面列子, 增加一個 Cat 類同樣是 Animal 的子類

class Cat: Animal {
    
}

class Person {
    func getAnimal() -> Animal {
        return Cat()
    }
    
    func feed(animal: Animal) {
        
    }
}

class Man: Person {
    override func getAnimal() -> Dog {
        return Dog()
    }
    
    override func feed(animal: Dog) {
        
    }
}
複製程式碼

Man 類是 Person 類的子類, 過載 Person 的方法, 方法的返回值和引數均改成 Dog, 會出現什麼問題嗎? 我們會發現 getAnimal() 方法過載返回值是成功的, feed(animal: Dog)過載引數失敗, 此時編譯器會報錯, 但是你知道錯誤的原因嗎?

再看下面例子:

let person: Person = Man()
let animal: Animal = Cat()
        
person.feed(animal: animal)
複製程式碼

這個例子充分說明了為什麼方法引數不能過載成子類, Man例項和 Animal例項均沒有問題, 但是下面的 feed(animal:) Man 類的例項就不能完全替換 Person 類了, 如上 Person 例項呼叫 feed(animal:) 傳入 Cat(), 因為 Person 的 feed(animal:) 方法需要 Animal 型別的引數, 所以傳入 Cat() 是合理的, 此時如果用 Man 類例項去替換 Person 類例項了, 由於 Man 類例項方法需要一個 Dog(), 它不接收 Cat() 引數, 此時 Man 類就不能完全替換 Person 類, 所以不符合Liskov substitution principle準則, 即 Man類不是 Person 類的子類, 但是 Man 類卻又繼承自 Person 類, 就自相矛盾了, 所以這種做法是錯誤的, 編譯器也會有錯誤提示. 這裡也證明了上面說的, 方法的輸入引數型別的關係和函式指標型別關係是相反的.

那上面有辦法解決嗎?

class Biology {
    
}

class Animal: Biology {
    
}

class Person {
    func getAnimal() -> Animal {
        return Cat()
    }
    
    func feed(animal: Animal) {
        
    }
}

class Man: Person {
    override func getAnimal() -> Dog {
        return Dog()
    }
    
    override func feed(animal: Biology) {
        
    }
}
複製程式碼

讓 Animal 類繼承自 Biology 類, Man 類過載 feed(animal:) 方法輸入引數過載為 Biology 類, 這樣無論 Animal 類是 Cat, Dog, ...都能接住, 也就是說 Man 類例項可以在任何時候任何的地方替換(賦值) Person 類例項, 說明 Man 類是 Person 類的子類, 也符合繼承規則.

所以這也是為什麼過載父類方法時, 引數不能是父類方法引數的子類, 而應該是父類引數型別或者父類引數的父類(超類). 一般我們過載時都預設是跟父類引數一致, 很少見到有超類的情況. 而方法返回值型別, 可以是父類方法返回值型別或者返回值型別的子型別.

過載方法時, 返回值型別可以是順著繼承鏈向下, 輸入引數型別是順著繼承鏈子向上

Properties

Swift 和 OC 的 Properties 按照讀寫屬性分為 read-onlyread-write. read-only 屬性其實就是呼叫 getter 方法, 所以 read-only 可以過載型別為子型別. read-write 屬性其實是呼叫一對方法: 不帶引數帶返回值的 getter 方法, 帶引數不帶返回值的 setter 方法, 結合上面所說子類過載父類方法的結論: 返回值型別 <= 父類返回值型別 && 引數型別 >= 父類引數型別, 同時滿足這兩個條件才能構成父子關係, 所以過載 read-write 屬性只能跟父型別屬性型別一致.

Generics

Generics 泛型非常強大, 當工程裡面有很多地方做相同的任務而且與型別無關時, 泛型就能發揮其強大的特性, 能夠讓程式碼更加的緊湊, 層次結構也更加清晰, 邏輯不會散落在工程各個地方.

In Swift

在 Swift 中簡單演示一下泛型的使用:

class Animal: NSObject {

}

class Dog: Animal {
    override var description: String {
        return "it's dog."
    }
}

class Cat: Animal {
    override var description: String {
        return "it's cat."
    }
}

class Person<T> {
    var pet: T?

    func feed(_ some: T) {
        print(some)
    }
}

let dog = Dog()
let cat = Cat()

let man: Person<Dog> = Person()
let woman: Person<Cat> = Person()

man.feed(dog)
woman.feed(cat)
複製程式碼

列印結果

it's dog.
it's cat.
複製程式碼

Person 類接收泛型 T, feed 處理傳入的泛型, 根據在初始化 person 時指定的泛型具體型別來處理, 還能給泛型增加限制, 比如遵守同一個 protocol, 繼承自同一個 base class 基類, 還可以用 where 語句限制具體引數的詳細條件, 這裡不細說泛型的用法, 來看看泛型的 subtype, supertype 的關係如何?

稍微修改一下上面的程式碼:

let man: Person<Animal> = Person()
let woman: Person<Cat> = Person()

man = woman
woman = man
複製程式碼

例項化 man 的時候指定泛型型別為 Animal, 那麼 Person<Animal>Person<Cat> 的父子關係如何呢? Animal 和 Cat 雖然是父子關係, 他們派生出的泛型型別是什麼關係呢? 測試上面的例子發現 man 和 woman 相互賦值均不成功, 說明 Person<Animal>Person<Cat> 沒有任何關係, 不會因為指定泛型具體型別是父子關係就是父子關係. 所以 Swift 的泛型是 invariance(不變)的.

真的是這樣嗎? 再看下面的列子:

var animalArray: Array<Animal> = [Animal()]
var dogArray: Array<Dog> = [Dog()]

animalArray = dogArray
dogArray = animalArray
複製程式碼

Swift 標準庫中 collection集合型別中廣泛地使用泛型, 比如陣列的定義:

public struct Array<Element> {
    ...
}
複製程式碼

animalArray 陣列指定 Animal 型別, dogArray 陣列指定 Dog 型別, 按照上面的結論推測這兩個賦值會失敗, 但是實際上只有 dogArray = animalArray 失敗, animalArray = dogArray 是成功的, 說明 Array<Animal>Array<Dog>的父類, WTF?

因為標準庫中的集合型別, 系統會做了很多處理讓其具有協變性, 所以在開發的時候我們會認為是理所當然的.

“Swift generics are normally invariant, but the Swift standard library collection types — even though those types appear to be regular generic types — use some sort of magic inaccessible to mere mortals that lets them be covariant.”

In OC

在 OC 使用的泛型其實是 Lightweight Generics 輕量級的泛型, 2015年的時候推出的新特性, 而且是編譯器層面的特性, 其目的昭然若揭, 一是為了擴充套件 OC 這門"古老"的語言, 更是為了方便從 OC 遷到 Swift, 熟悉泛型特性. OC 裡面泛型的寫法用法與 Swift 類似, 但是有些地方需要注意, 泛型只能在 @interface 裡使用, 在 @implementation 中只能用 id 型別, 顯然泛型是介面型別, 通過介面暴露出去.

/**
 Animal class
 */
@interface Animal: NSObject

@end

@implementation Animal

- (NSString *)description {
    return @"it's animal";
}

@end

/**
 Dog class
 */
@interface Dog: Animal

@end

@implementation Dog

- (NSString *)description {
    return @"it's dog";
}

@end


/**
 Person Class
 */
@interface Person<T: Animal *>: NSObject

@property (nonatomic, strong) T pet;

- (void)feedAnimal: (T)animal;

@end

@implementation Person

- (void)feedAnimal:(id)animal {

}

@end
複製程式碼

上 Person 類中簡單演示一下泛型的使用方式, <T: Animal *> 表示限制泛型 T 的型別是 __kindof Animal 型別. 我們主要研究一下 OC 中泛型的型別關係:

Person<Animal *> *man = [[Person alloc] init];
Person<Dog *> *woman = [[Person alloc] init];

man = woman;
woman = man;
複製程式碼

Person<Animal *>Person<Dog *> 相互賦值編譯器會報 warning, 而不像 Swift 中直接報錯, 因為 Swift 是型別安全語言, 相對於 OC 語言更加安全, 把問題暴露在編譯階段, 但是也增加了編譯的時間. 所以上面的賦值語句並不成功, Person<Animal *>Person<Dog *> 這兩個型別並不是父子關係.

Incompatible pointer types assigning to 'Person<Animal *> *' from 'Person<Dog *> *' Incompatible pointer types assigning to 'Person<Dog *> *' from 'Person<Animal *> *'

但是在 OC 泛型裡有兩個 Swift 不具備的引數: __covariant, __contravariant. 用 __covariant 修飾泛型時, 派生出的類能夠"協變", 也就是說 Animal 類與 Dog 類是父子關係, 那麼用 __covariant 修飾 T 後, Person<Animal *>Person<Dog *> 是父子關係; __contravariant 修飾泛型時, 派生出的類能夠"逆變", 即Person<Animal *>Person<Dog *> 是子父關係.

@interface Person<__covariant T: Animal *>: NSObject
    ...
@end

Person<Animal *> *man = [[Person alloc] init];
Person<Dog *> *woman = [[Person alloc] init];

// success
man = woman;
// fail
woman = man;
複製程式碼

上面用 __covariant 修飾泛型 T, 那麼 Person<T> 具備協變性, 所以Person<Animal *> 是 Person<Dog *> 的父類; 用 __contravariant 修飾泛型 T, Person<T> 具備逆變性, 所以 Person<Animal *>Person<Dog *> 的子類, 這種場景還真沒有遇見過.

OC 中的集合型別又是如何呢? 是否跟 Swift 中一樣具備協變性呢? 看看 NSArray 的介面便知 Foundation 中的集合型別自帶協變性.

@interface NSArray<__covariant ObjectType> : NSObject <NSCopying, NSMutableCopying, NSSecureCoding, NSFastEnumeration>
 ...
@end
複製程式碼

所以, 在 OC 裡面可以通過 __covariant, __contravariant 來指定泛型型別是協變還是逆變, 但是在 Swift 中就不能, Swift 中自定義的泛型型別是 invariance(不變)性的, 如果想具備協變或者逆變性, 就得想法子繞過, 後面的文章會講.

Covariance, Contravariance, invariance

上面一直都在討論閉包, block, 方法, 屬性, 泛型之間的型別關係, 能感知到 Covariance 協變, Contravariance 逆變, invariance 不變這三種屬性並不難理解, 而且跟實際開發關係密切.

Covariance

Covariance is when subtypes are accepted.

Covariance 協變是子型別被接受. 英語中很多地方是被動句, 換句話說就是: 一個型別能接收它的子型別, 那麼此型別就具備協變性. 一言以蔽之: 子型別能賦值給父型別.

通過我們上面的 subtype, supertype的演示, 有哪些型別具備協變性呢?

  • 系統提供的集合型別: Array/NSArray, Dictionary/NSDictionary...
  • function / closure / block 沒有輸入引數時, 返回值的型別關係是順著繼承鏈向下.
  • function / closure / block 有輸入引數時, 輸入引數的型別關係是順著繼承鏈向上的, 且返回值的型別關係是順著繼承鏈向下.
  • Overridden read-only 屬性.
  • OC 中用 __covariant 修飾泛型時.

協變在開發中應用廣泛, 最常見的就是當你給 closure/block 賦值時, 有時引數不對也許會糾結半天.

Contravariance

Contravariance is when supertypes are accepted.

Contravariance 協變是父型別被接受. 換句話說就是: 一個型別能接收它的父型別, 那麼此型別就具備協變性. 一言以蔽之: 父型別能賦值給子型別.

逆變

  • function / closure / block 沒有輸入引數時, 返回值的型別關係是順著繼承鏈向上.
  • function / closure / block 有輸入引數時, 輸入引數的型別關係是順著繼承鏈向上, 且返回值的型別關係是順著繼承鏈向下/向上.
  • Overridden read-write 屬性順著繼承鏈向下/向上.
  • OC 中用 __contravariant 修飾泛型時.

Contravariance 在實際開發中還真沒見過, 在 Swift 中基本能立馬報錯, OC 裡只會有警告, 但是知道它能方便快速定位問題.

invariance

Invariance is when neither supertypes nor subtypes are accepted.

Invariance 協變是既不接受子型別也不接受父型別. 也就是沒有子型別和父型別, 能稱為子型別或父型別必然具備協變或逆變屬性.

  • Swift, OC 自定義的泛型型別.

協變/逆變能反映出源型別的關係如何影響到派生複雜型別的關係, 瞭解由源型別派生出的複雜型別的關係是很重要的, 即使在 Swift 強型別安全下也是很有必要的, 知道報錯的原因, 在 OC 裡面更是有必要, 不同型別之間賦值關係混亂會造成執行時各種崩潰, 而在編譯階段是沒法檢測出來的, 如果做靜態檢查可以檢測出不同型別間賦值的問題的話, 成本也是比較高的, 所以最好是在程式碼編寫階段就杜絕此類問題的發生.

歡迎大家斧正!

相關文章