Swift 值類型和引用類型深度對比

值類型和引用類型是Swift中的核心概念。 毋庸置疑,瞭解它們是每位Swift開發人員的基礎。 在本文中,咱們將討論下面的問題:git

  • 值類型和引用類型的概念
  • 他們在內存中時如何存儲的?
  • 值類型和引用類型分別有哪些表現?
  • 若是將二者混合使用會怎樣?
  • 何時使用值類型,何時使用引用類型?

定義值類型和引用類型

Swift有三種聲明類型的方式:classstructenum。 它們能夠分爲值類型(struct和enum)和引用類型(class)。 它們在內存中的存儲方式不一樣決定它們之間的區別:github

  • 值類型存儲在棧區。 每一個值類型變量都有其本身的數據副本,而且對一個變量的操做不會影響另外一個變量。swift

  • 引用類型存儲在其餘位置(堆區),咱們在內存中有一個指向該位置的引用。 引用類型的變量能夠指向相同類型的數據。 所以,對一個變量進行的操做會影響另外一變量所指向的數據。數組

從性能出發

致使Swift結構體(和枚舉)與類的性能差別的三個維度是:bash

  1. 複製消耗的成本;
  2. 建立和銷燬時花費成本;
  3. 引用計數形成的成本;

下面咱們可能會常常討論內存,所以請確保你瞭解什麼是內存以及內存是如何存儲數據的。數據結構

內存段

內存能夠理解爲字節的集合。字節在內存中有序排列,每一個字節都有本身的地址。 全部地址的範圍稱爲地址空間。閉包

iOS應用程序的地址空間在邏輯上由四個部分組成:代碼段,數據段,棧區和堆區:app

1

代碼段包含構成App可執行代碼的機器指令。 它是由編譯器經過將Swift代碼轉換爲機器代碼而產生的。 該段是隻讀的,並佔用固定不變的空間。ide

數據段存儲Swift靜態變量,常量和類型元數據。 程序啓動時全部須要初始值的全局數據都在此處。函數

棧區存儲臨時數據:方法的參數和局部變量。 每次咱們調用一個方法時,都會在棧上分配一塊新的內存。 該方法退出時,將釋放該內存。 除特殊狀況(下面會講),全部Swift值類型都在此處。

堆區存儲具備生存期的對象。 這些都是Swift引用類型,還有一些值類型的狀況。 堆和棧朝着彼此增加堆區的分配通常按照地址從小到大進行,而棧區的分配通常按照地址從大到小進行分配

通常Swift值類型在棧上分配。 引用類型在堆上分配。

如今,咱們已經研究了內存段的工做原理,讓咱們來看一下內存中的內容是如何存儲的。

堆與棧分配的成本

棧區內存分配和銷燬的工做原理與數據結構中的棧相同。 你只能從棧頂壓棧或出棧。 指向棧頂的指針足以實現這兩個操做。 所以,棧指針能夠騰出空間來分配其餘更多的內存。 當函數執行完退出時,咱們將棧指針增長到調用此方法以前的位置。(爲何增長才能回到調用以前的地址,剛說了棧是從大到小進行分配的)

棧分配和釋放的成本至關於整數複製的成本【WWDC-416】

堆分配過程涉及的東西不少。 咱們必須搜索堆區以找到適合它大小的空內存塊。 咱們還必須同步堆,由於多個線程可能同時在其中分配內存。 爲了從堆中釋放內存,咱們必須將該內存從新插入適當的位置。

堆分配和釋放的成本比棧要大得多

一般值類型和引用類型分別在棧和堆上分配,可是這個規則有一些例外狀況須要注意。

Swift 引用類型關於棧的優化

當引用類型的大小固定或能夠預測生存期的時候,Swift編譯器可能會將引用類型分配到棧中。 這種優化發生在SIL生成階段。

Swift中間語言(SIL)是Swift特有的高級中間語言,用於對Swift代碼的進一步分析和優化。

下面是我經過閱讀Swift編譯器源代碼發現的示例。

Swift值類型 -- 裝箱

Swift編譯器能夠將值類型裝箱後放到堆上。 我經過閱讀Swift編譯器源代碼來列出了會出現的幾種狀況。

在如下狀況,值類型會被裝箱:

  1. 當值類型遵循了某個協議

    當值類型遵循了某個協議,且存儲在existential(存在性)容器中超過3個機器字長時,除分配成本外,還會產生額外的開銷。

    Existential(存在性)容器是用於存儲運行時未知類型的值的一種通用容器。 較小的值類型能夠內嵌在存在性(existential)容器中。 較大的分配在堆上, 它們的引用存儲在存在性(existential)容器緩衝區內。 此類值的生存期由值見證表(Value Witness Table)管理。 當調用協議方法時會產生引用計數和幾個間接級別的開銷。

    值見證表(Value Witness Table): 一種運行時結構,用於描述如何對未知值進行「 assign」,「 copy」和「 destroy」基本操做。 (例如,複製此值是否須要保留?)

    詳解見官方:github.com/apple/swift…

    讓咱們看看生成的SIL代碼他們是如何裝箱的。 咱們聲明一個協議Bar和一個符合它的結構體 Baz

    protocol Bar {}
    struct Baz: Bar {}
    複製代碼

    Swift文件轉換成SIL語言的命令是:

    swiftc -emit-silgen -O main.swift
    複製代碼

    輸出顯示self被裝在init()中:

    protocol Bar {
    }
    struct Baz : Bar {
      init()
    }
    // Baz.init()
    sil hidden [ossa] @$s6boxing3BazVACycfC : $@convention(method) (@thin Baz.Type) -> Baz {
    bb0(%0 : $@thin Baz.Type):
      %1 = alloc_box ${ var Baz }, var, name "self"   // user: %2
      ...
    }
    複製代碼
  2. 值類型和引用類型混合時

    結構體中包含類,類中包含結構的狀況很常見:

    // Class inside a struct
    class A {}
    struct B { 
      let a = A() 
    }
    
    // Struct inside a class
    struct C {}
    class D {
        let c = C()
    }
    複製代碼

    SIL輸出顯示,在兩種狀況下,結構BC都分配在堆上:

    // B.init()
    sil hidden [ossa] @$s6boxing1BVACycfC : $@convention(method) (@thin B.Type) -> @owned B {
    bb0(%0 : $@thin B.Type):
      %1 = alloc_box ${ var B }, var, name "self"     // user: %2
      ...
    }
    
    // C.init()
    sil hidden [ossa] @$s6boxing1CVACycfC : $@convention(method) (@thin C.Type) -> C {
    bb0(%0 : $@thin C.Type):
      %1 = alloc_box ${ var C }, var, name "self"     // user: %2
      ...
    }
    複製代碼
  3. 帶有泛型的值類型。

    讓咱們聲明一個帶泛型的結構體:

    struct Bas<T> {
        var x: T
    
        init(xx: T) {
            x = xx
        }
    }
    複製代碼

    SIL輸出顯示self被裝在init(xx :)中:

    // Bas.init(xx:)
    bb0(%0 : $*Bas<T>, %1 : $*T, %2 : $@thin Bas<T>.Type):
      %3 = alloc_box $<τ_0_0> { var Bas<τ_0_0> } <T>, var, name "self" // user: %4
      ....
    }
    複製代碼
  4. 逃避閉包捕獲時。

    Swift的閉包對全部局部變量都是經過引用來捕獲的。 如CapturePromotion中所述,有些可能仍被放在棧中。

    CapturePromotion github.com/apple/swift…

  5. Inout參數

    讓咱們爲foo(x :)生成一個接受inout參數的SIL:

    func foo(x: inout Int) {
        x += 1
    }
    複製代碼

    SIL輸出顯示foo(x :)正在裝箱:

    // foo(x:)
    sil hidden [ossa] @$s6boxing3foo1xySiz_tF : $@convention(thin) (@inout Int) -> () {
    // %0                                             // users: %7, %1
    bb0(%0 : $*Int):
    ...
    }
    複製代碼

複製的成本

衆所周知,大多數值類型都分配在棧上的,複製它們須要花費固定的時間。 複製操做速度快的緣由是整數和浮點數等基本數據類型存儲在CPU寄存器中,複製它們時無需訪問RAM內存。 Swift的大多數可擴展類型(例如字符串,數組,集合和字典)都在寫入時被複制了( copied on write)。 這意味着複製操做消耗很小。

因爲引用類型不會直接存儲其數據,所以咱們在複製它們時只會產生引用計數成本。 引用計數的增長和減小不像整數變化那麼簡單,還須要額外的花銷。由於堆可能同時被多個線程共享,爲了保持原子性也須要額外花銷。

關於ARC的討論和堆分配對象的生命週期咱們未來會開專題討論,歡迎關注公衆號:樂Coding獲取最新文章。

當咱們混合使用值和引用類型時,事情變得頗有趣。 若是結構體或枚舉包含引用類型時,它們須要的引用計數開銷與他們包含引用類型的數量成正比。 下面的代碼示例能夠最好地證實這一點。 讓咱們建立一個擁有引用類型屬性的結構體和一個具備引用類型屬性的類,並打印他們的引用計數。

class Ref {}

// Struct with references
struct MyStruct {
    let ref1 = Ref()
    let ref2 = Ref()
}

// Class with references
class MyClass {
    let ref1 = Ref()
    let ref2 = Ref()
}
複製代碼

讓咱們爲MyStruct打印引用計數:

let a = MyStruct()
let anotherA = a
print("self:", CFGetRetainCount(a as CFTypeRef))
print("ref1:", CFGetRetainCount(a.ref1))
print("ref1:", CFGetRetainCount(a.ref2))
複製代碼

打印結果:

self: 1
ref1: 2
ref1: 2 
複製代碼

再來看看MyClass:

let b = MyClass()
let anotherB = b
print("self:", CFGetRetainCount(b))
print("ref1:", CFGetRetainCount(b.ref1))
print("ref1:", CFGetRetainCount(b.ref2))
複製代碼

打印:

self: 2
ref1: 1
ref1: 1
複製代碼

輸出顯示MyStruct結構體產生了兩倍的引用計數成本😱。

結構體和類的選擇

對於應該使用類仍是結構,沒有簡單的答案。 儘管蘋果建議在對具備標識(identity)的東西使用類,其餘狀況使用結構,但這不足以指導咱們作出決定。 因爲每種狀況都不一樣,咱們還須要慮性能:

  • 應當避免值類型包含引用類型的變量,由於它們違反了值的語義併產生額外的引用計數開銷。
  • 具備動態行爲的值類型(例如數組和字符串)應採用copy-on-write來攤銷複製成本。
  • 值類型遵循協議時將被裝箱,從而致使更高的建立成本。

咱們應該儘可能避免以上狀況的發生,除此以外能夠根據你的需求選擇合適的類型。


logo
相關文章
相關標籤/搜索