Swift記憶體賦值探索一: 理解物件在記憶體中的儲存狀態

weixin_33866037發表於2018-03-16

這系列記錄的文章是由一個實際需求引發的 —— 我們能夠在 OC 中暢快的使用 KVC ,而在 Swift 中,如果想要使用 KVC ,那麼操作物件應該直接或者間接繼承 NSObject 。原因是 KVC 屬於 Runtime 中的內容,而 Runtime 專屬於 OC ,所以我們要使用 KVC 就必須使用 OC 的類來進行。

KVC 有哪些好處我就不多說,越是深入到 Runtime 中越能體會到它的強大。一個簡單的例子就足以說明 —— 如果我們希望對一個例項物件的所有屬性賦值,除了最簡單而又最繁瑣的直接使用.語法逐個賦值之外,還有一種更加簡單的方式:通過 Runtime 獲取物件所有屬性的集合,然後遍歷這個屬性,使用 KVC 統一賦值 —— 這可以寫成一個分類,最後暴露的僅僅是一個介面了。

如果 Swift 要做到這一步該怎麼做? 本人不才,除了繼承 OC 類使用 KVC ,並沒有找到其他現成的方案去實現純 Swift 的 “KVC” 。好吧,這確實是一個悲傷的故事。
而這一切並非沒有轉機:直到我使用 HandySwift 這個Json 和 Swift 物件互轉的框架之後,不僅感嘆作者的思路開闊,當然更多的是驚喜,在純 Swift 中實現 KVC 就靠它了: HandySwift 使用了記憶體賦值的方式,在“暗地裡”給純 Swift 類的例項物件賦值,避免了開發者面臨非 OC 物件需要逐個點語法賦值的尷尬。這不正是另形式的 “KVC” 嗎?

這一系列文章就如何實現 記憶體賦值 進行的探究做了一個記錄。

1. 物件如何存在記憶體中

我們申請一個物件之後,系統會為它分配記憶體,至於分配在哪裡,這將視情況而定,一般來說,區域性變數等都會放在棧區,它的作用域很小。 而一個常量或者全域性變數則一般會在堆中。我們今天討論的主角是 Swift 中的例項物件的堆存賦值情況,大多數的使用場景是在全域性的狀態下,也即是在堆區討論。 其實就算是在棧區也是一樣的,只要物件還存活,我們只需要獲取它的地址,然後賦值就達到了我們的目的。
Swift 中,記憶體有三種狀態:

  • 未繫結型別同時未初始化值
  • 繫結型別但是未初始化值
  • 繫結型別同時初始化值

如果我們申請一個物件,除了基礎物件之外,複雜的物件都會申請一個指向它的記憶體的指標,我們通過指標區訪問這個記憶體中的值。但是為了正確的得到被訪問值所佔空間的真實大小,還需要指定這個記憶體的型別,以便快速得到記憶體中的值。

在 Swift 中,任何一個存活的物件都有對應的儲存記憶體,這個記憶體初始化的值就是該物件,而相對應的記憶體的型別就是物件的型別。 在初始化物件的時候,系統根據物件屬性的型別和記憶體對齊的規則計算物件記憶體的大小,之後,物件的屬性修改也將在各個屬性的地址上做修改。

比如,如果有一個 Person 類:

class Person {
     var name: String!   
     let  isMan: Bool!
     var age: Int!
     var hight:Double!

     init...
}

let p = Person()

物件p被初始化之後,我們分析一下它的記憶體。
本身p就是一個指標,這個指標的型別是Person,記憶體中的值就則為p的內容。而在這個指標p指向的區域中,存放name的那部分記憶體的型別應該是一個String型別,其內部的值為name指標,除了String是指標之外,其餘的都是基本資料型別, 他們對應的記憶體的型別是各自的型別,並且值則為各自的值。
假設String 型別佔2個位元組,Bool 佔 1 個位元組,Int 佔4個位元組,Double 8個位元組。
如果不考慮記憶體對齊的話,那麼 p 佔用的記憶體應該為15個連續位元組。排列看起來像這樣:

8115459-e186261e38dc1de4.png
無記憶體對齊

這樣緊密排布,看似很節省空間,但是因為 CPU 定址的方式的特點,這種連續儲存的方式,會導致訪問的效率急劇下降。因此,實際上對於程式語言來說,還需要進行專門的記憶體使用設計 —— 記憶體佈局,以便提升 CPU 的訪問效率,其中記憶體對齊就是其一。
假如,Swift在64位裝置上記憶體對齊模數為 8 ,對於p來說,它的真實記憶體佈局應該是這樣的:

8115459-db6fb070cf10af83.png
記憶體對齊之後

也即是一共需要 32位元組的記憶體空間。跟我們想象中的不太一樣,實際上假如有屬性出現可選類的時候,記憶體的佔用將更大:比如將hight屬性的型別更改為 Double?,這時候他將佔用 9 個位元組,如果算上記憶體對齊,它需要的實際空間是 16 個位元組。其他的各種記憶體佈局方式跟程式語言的記憶體模型有關。

2. 如何給記憶體中的物件的屬性賦值

僅僅知道每個物件的儲存原則是不夠的,我們不知道物件的每個屬性是如何在物件記憶體區域儲存的。當我們已經深入到了記憶體儲存的時候,有關的類的大部分資訊就不再能夠使用正常的渠道獲取。比如,儘管我們知道某一塊的記憶體中存了一些我們想要的資訊。但是我們並不夠準確知道,每一個地址區域對應的內容是什麼。
好在,Swift 為型別物件保留了一個關於物件的記憶體佈局描述 —— MemoryLayout

當使用指標分配或繫結記憶體時,可以使用MemoryLayout作為關於型別資訊的來源。
再看看MemoryLayout中的一些內容:

 static var alignment: Int

The default memory alignment of `T`, in bytes.

static var size: Int

The contiguous memory footprint of `T`, in bytes.

static var stride:<wbr> Int

The number of bytes from the start of one instance of `T` to the start of the next when stored in contiguous memory or in an `Array<T>`.

內部包含了一個物件的佔用空間: size, 實際長度 stride, 以及對齊模數 alignmentMemoryLayout很有用,我們通過這個類對不同的裝置做記憶體佈局的適配。有了這個三個屬性,我們可以對一個物件基本可以確定一個物件在不同的裝置上的佔用記憶體資訊。但是這些資訊依然不夠,因為這個記憶體佈局並沒有體現每一個屬性的記憶體佈局資訊。

HandySwift 的作者在實現記憶體賦值的時候也遇到了這個問題,他通過總結髮現 Swift 中,一個類例項的屬性記憶體佈局是有規律的:

32位機器上,類前面有4+8個位元組儲存meta資訊,64位機器上,有8+8個位元組;
記憶體中,欄位從前往後有序排列;
如果該類繼承自某一個類,那麼父類的欄位在前;
Optional會增加一個位元組來儲存.None/.Some資訊;
每個欄位需要考慮記憶體對齊;(包括 meta 資訊)
作者:xycn
連結:https://www.jianshu.com/p/eac4a92b44ef
來源:簡書

這個規律在 HandySwift 中使用了很久,並且執行良好,我們可以認為上述規律是可行的。

這其中有個問題:如何獲取物件的全部屬性,這裡注意要包含父類的繼承屬性。Swift 提供了反射機制 Mirro ,通過 Mirro 我們能夠逐一獲取到物件的所有屬性資訊,包含了屬性的值和型別,唯一遺憾是我們並不能對屬性賦值(不然這篇文章可以燒掉了)。並且這個資訊是從父類開始,按欄位有序排列,剛好符合我們的規律需求。

至於如何賦值就比較簡單了,使用指標賦值就可以了。儘管 Swift 不推薦開發者使用指標,但是並非不能使用,詳細的內容在後面的文中會介紹。

到這裡,對於如何在賦值我們已經有了思路:

  • 找到一個物件的起始地址,捨去 meta 相關的無用地址。找到第一個有效的地址。
  • 通過遍歷 Mirro 反射得到的屬性列表,按照每個屬性的型別計算它所佔的位元組數,注意,這裡要考慮到記憶體對齊的情況,不然會出事。同時因為有了首地址,後面的地址逐個累加過去即可。
  • 使用指標給對應的記憶體賦值。
  • 接下來就是迴圈2、3。 直到屬性列表中所有的屬性全部賦值完畢。

以上思路完成了對一個物件的所有屬性通過記憶體賦值的全過程。 實際上的操作肯定比這個要複雜的多了。 我們下篇文章中揭曉。

參考:
記憶體對齊概念
記憶體模型
HandyJSON設計思路簡析

相關文章