Tagged Pointer 字串

SwiftGG翻譯組發表於2018-10-08

作者:Mike Ash,原文連結,原文日期:2015-07-31 譯者:jojotov;校對:Forelax冬瓜;定稿:Forelax

Tagged pointer 是一項用於提高效能並減少記憶體使用的有趣技術。在 OS X 10.10 中,NSString 也開始使用了 tagged pointer 技術,今天我會在 Ken Ferry 的提議下,窺探其工作原理。

概述

物件儲存在記憶體中的時候是記憶體對齊的,因此他們的地址總是單個指標大小的倍數,在實際中通常是 16 的倍數。物件的指標通常是以一個完整的 64 位整型的結構進行儲存,不過由於記憶體對齊的,指標中一些位總會為零。

Tagged pointer 技術受益於此,通過讓這些位不再為 0,賦予了物件指標一些特殊意義。在蘋果的 64 位 Objective-C 實現當中,物件指標的最低有效位設定為 1 的時候 (也就是說,它是一個奇數) ,此指標被認為是 tagged pointer。此時,最低有效位前面的 3 位不再被當作 isa 指標的地址,而是用於表示一個特殊的 tagged class 表的索引值。這個索引值可以用來查詢 tagged pointer 所對應的類。剩餘的 60 位則會被直接使用。

來看一個對上述理論的簡單應用:當我們建立一個 NSNumber 物件時,如果它適合於 tagged pointer 技術,那麼這個物件將不再是一個真正的 NSNumber 物件——它的指標會自動轉換為 tagged pointer 指標,並且最低位會被設定為 1;接下來的 3 位會設為 NSNumber 所對應的 tagged class 在一個全域性表中的索引;而剩餘的 60 位會用作儲存其數值 —— 比如一個能用 60 位表示的整型值。

對於外部而言,這樣的一個指標與其他任何物件的指標看起來都是一樣的。它能像其他物件一樣響應訊息,因為 objc_msgSend 知道它是一個 tagged pointer 型別的指標。假如你要向它傳送 integerValue 的訊息,OC 執行時會幫助我們從它儲存資料的 60 位中拿出資料並返回。

儘管為了對外統一,執行時做了很多額外工作,但最終你節省了一次記憶體的初始化,一次指標的間接訪問,並且也不會有任何關於引用計數的操作 —— 因為沒有記憶體需要被釋放。對於一些經常使用的類來說,這能帶來顯著的效能提升。

NSString 看起來不太適用於 tagged pointer 技術,因為它的長度是可變的,而且可能會遠遠超出 tagged pointer 所能儲存的範圍。但話雖如此,一個 tagged pointer 的類是可以和普通的類共存的 —— 某些值使用 tagged pointer,另外一些值使用普通指標。例如,對於 NSNumber 來說,一個大於 2^60 - 1 的整型超出了 tagged pointer 所能儲存的範圍,那麼它就需要儲存為一個在記憶體中初始化的普通 NSNumber 物件。

NSString 亦是如此。假如某些字串可以儲存為 60 位以內的二進位制資料,它會建立為 tagged pointer,而其他字串會儲存為普通的物件。據此我們可以假設,如果小的字串經常被使用且達到一定的使用量時,它會獲得可觀的效能。在真實的程式碼中會有如此效果嗎?顯然蘋果給出了肯定的答案 —— 如果沒有實際效果,他們不會嘗試去實現它。

可能的實現

在窺探蘋果的實現之前,我們先花點時間思考一下可能的實現方案。基本準則很簡單:把最低位設為 1,然後把後面的幾位設為合適的 tagged class 索引,最後把剩下的位設為任意值。此時最大的問題是如何利用剩餘的 60 位 —— 我們要儘可能最大化這 60 位的價值。

Cocoa 框架中的字串在某種概念上其實是一個 Unicode 碼位的序列。Unicode 包含了 1,112,064 個有效碼位,所以一個碼位需要用 21 位來表示。也就是說,我們可以在 60 位的長度中放入兩個 Unicode 碼位,這樣還剩下 18 位沒有使用。我們可以利用這 18 位中其中幾位來表示字串的長度。因此,這樣的一個 tagged pointer 字串可能會包含零個、一個或者兩個 Unicode 碼位。但問題是,最多隻能包含兩個碼位好像並不太實用。

實際上,NSString API 使用了 UTF-16 實現,並非原始的 Unicode 碼位。UTF-16 把 Unicode 表示為一個包含多個 16 位數值的序列。在基本多語言平面(Basic Multilingual Plane,BMP)中的字元,也就是那些最常用的字元,會使用一個 16 位的值表示。同時,那些超過 65,535 的碼位會使用兩個 16 位 (也就是 32 位) 的值來表示。因此,我們可以在 60 位的長度中放入三個 16 位的值,剩餘的 12 位同樣用於表示長度。也就是說,我們可以放入 0 至 3 個 UTF-16 編碼的字元 —— 嚴格來說是最多三個 BMP 中的字元,或者最多一個 BMP 之上平面的字元加一個 BMP 平面之下的字元。不過最多三個字元,我們還是會很受限。

在應用中的大多數字符串都是 ASCII。即使這個應用本地化為一種非 ASCII 的語言,字串也不只是單純地在 UI 層用作展示 —— 字串會用於 URL 的組成、副檔名、物件的鍵、屬性列表的值等等。UTF-8 是一種相容 ASCII 的編碼,它會把每個 ASCII 字元編碼為單個位元組,並且對其他 Unicode 碼位使用最多四個位元組進行編碼。這樣,我們能在 60 位中放入最多 7 個位元組,剩下 4 位表示長度。因此,根據不同的字元格式,我們的 tagged pointer 字串可以包含最多 7 個 ASCII 字元,或者更少量的非 ASCII 字元。

如果我們針對 ASCII 優化一下,或許我們能完全拋棄對所有 Unicode 都支援的想法 —— 那些非 ASCII 的字元都使用真正的字串物件來儲存。ASCII 是一種 7 位的編碼方式。因此,假設我們只給每個字元分配 7 位的空間呢?那麼我們能在可利用的 60 位空間中儲存最多 8 個 ASCII 字元,剩餘 4 位表示長度。現在,我們的方案聽上去開始具有一定的可行性了 —— 在應用中應該有不少字串是純正的 ASCII 並且僅包含 8 個或更少的字元。

讓我們的思維放飛一點,完整的 ASCII 範圍中包含了許多並不常用的東西。比如其中有一大堆控制字元和非常用符號。而字母和數字才是我們最常用的。我們能把它壓縮成只有 6 位嗎?

6 位可以表示 64 個可能的值。ASCII 字母表中有 26 個英文字母。如果把大寫字母也算上的話一共有 52 個字母。再加上 0-9 的數字,一共有 62 個。現在還有兩個空餘的位置,我們可以把它們留給空格和句號。這樣應該會有很多的字串只包含上述的字元。如果一個字元只需要 6 位,那麼我們可以在 60 位空間中儲存最多 10 個字元!不過別高興太早,我們現在沒有多餘的位置來表示長度了!因此,我們可以選擇這 60 位儲存 9 個字元和 1 個長度,或者我們去掉上面的 64 個值之一 (我投票給空格),然後用一個 6 位的 0 表示少於 10 個字元的字串的結束位。(譯者注:去掉 64 個字符集合中的一個,然後加入一個結束符,當遇到結束符的時候就表示字串結束,長度為結束符的位置,否則長度剛好為 10。)

如果只使用 5 位呢?這好像有點天方夜譚。但實際上,應該有很大一部分的字串只包含小寫字母。5 位可以表示 32 個可能的值。如果我們把整個小寫字母表考慮進來,那麼還剩下 6 個位置,可以分配給一些常用的大寫字母、符號和數字。如果你覺得這些除小寫字母外的情況更為常見,你甚至可以去掉一些不常用的小寫字母,比如字母 q。每個字元只使用 5 位的話,那麼我們可以存放 11 個字元並且還能有存放長度的空間,或者我們儲存 12 個字元,並採用結束符的方案表示長度。

讓我們的思維再飛遠一點。每個字元只使用 5 位似乎已經是在字母表長度固定的前提下的最優解了。不過你可以使用一些變長的編碼,例如 Huffman 編碼。這樣的話,對於一個常見的字母 e,可以使用比字母 q 更少的位表示。也就是說,假設你的字串全都是 e,那字串的每個字元最少可以只用 1 位表示。但這樣的代價是你的程式碼會變得更加複雜,且效能或許較差。

蘋果到底是採用哪種方案的呢?我們現在來一探究竟。

Tagged Pointer 字串實踐

下面的程式碼建立了一個 tagged pointer 字串並列印了它的指標:

NSString *a = @"a";
NSString *b = [[a mutableCopy] copy];
NSLog(@"%p %p %@", a, b, object_getClass(b));
複製程式碼

這裡 mutableCopycopy 的操作可能會讓人費解,但它卻是必須的。其中有兩個原因:首先,儘管一個像 @"a" 這樣的字串可以被儲存為 tagged pointer 字串,但如果是常量字串的話,那麼它永遠不會儲存為 tagged pointer 字串。常量字串必須保證能夠相容不同的作業系統版本,但 tagged pointer 字串的內部細節卻不保證能相容。如果只是蘋果的執行時程式碼所生成的 tagged pointer,它不會有任何問題。但如果像常量字串一樣,編譯器把它們嵌入在二進位制檔案中時,就可能會發生崩潰的問題。因此,我們需要對常量字串進行 copy 操作來拿到一個 tagged pointer。

必須進行 mutableCopy 操作的原因是,NSString 對我們來說十分的 “聰明” ,它能知道一個對不可變字串的 copy 其實是一個毫無意義的操作,並返回原來的字串作為 copy 操作後的值。因為常量字串是不可變的,所以 [a copy] 的返回值其實與 a 是一樣的。不過,mutableCopy 會強制進行真正的拷貝操作(深拷貝),然後對這樣一個深拷貝後的結果再進行一次不可變拷貝操作後,足以讓系統返回給我們一個 tagger pointer 字串。

譯者注:[a mutableCopy] 會在執行時建立一個可變字串(深拷貝),因此避免了上面原因一中關於常量字串的情況。但由於 mutableCopy 後的物件是一個可變物件,不可能為 tagged pointer,因此需要再對此可變副本進行一次 copy 操作。這次 copy 會在執行時返回一個新的不可變副本(深拷貝),避免了上面原因二中對常量字串拷貝返回原值的情況(淺拷貝),進而保證了最後返回的物件是經過執行時建立出來的(tagged pointer 物件只會在執行時建立)。

注意,你一定不可以在自己的程式碼中依賴這些細節!NSString 的程式碼返回一個 tagged pointer 給你的情況並不是一成不變的,如果你編寫的程式碼不知怎麼地依賴於此,那它最終可能會導致崩潰。幸好,正常且合理的程式碼不會有任何問題 —— 讓你可以幸福地忽略所有 tagged 相關的東西。

上面的程式碼在我的電腦上列印如下:

0x10ba41038 0x6115 NSTaggedPointerString
複製程式碼

首先,你可以看到原始的指標 —— 一個用來表示物件指標的整數。第二個值為 copy 後的指標,它非常清晰地表示出 tagged pointer 的特性:首先,它是一個奇數,也就是說它不會是一個有效的物件指標(記憶體對齊的關係)。同時,它是一個很小的數。在 64 位 Mac 系統的地址空間中,一開始的 4GB 是沒有任何對映且不能建立對映的空頁。因此,這個屬於空頁的地址也很好地證明了它不可能是一個物件指標。

我們可以從 0x6115 這個值推斷出什麼呢?首先我們可以知道最低的 4 位是 tagged pointer 機制本身的一部分。最低的十六進位制數字 5 在二進位制中為 0101。最低位的 1 表明了它是一個 tagged pointer。剩下的 3 位表明了它的 tagged class —— 在這個例子中是 010,表明了 tagged pointer 字串類的索引值為 2。不過這些資訊並不能提供給我們什麼有用的東西。

而上面例子中十六進位制地址的 61 則很值得我們探討一番。在十六進位制中,61 剛好為字母 a 的 ASCII 編碼。還記得這個指標所指向的值嗎 —— 正好就是字母 a!看起來這裡直接使用了 ASCII 編碼的值,真是個方便而又合適的選擇!

接下來列印出的類名明顯地表明瞭它的類是什麼,並且也提供了一個非常不錯的切入點,來讓我們深入其真實原始碼一探此特性的本質實現。我們很快會進入這一階段,不過在此之前先做點額外的檢查。

這裡通過一個迴圈構造出 abcdef... 的字串,同時把屬於 tagged pointer 的字串指標一個接一個地列印出來。

NSMutableString *mutable = [NSMutableString string];
NSString *immutable;
char c = 'a';
do {
    [mutable appendFormat: @"%c", c++];
    immutable = [mutable copy];
    NSLog(@"0x%016lx %@ %@", immutable, immutable, object_getClass(immutable));
} while(((uintptr_t)immutable & 1) == 1);
複製程式碼

第一次迭代的列印結果為:

0x0000000000006115 a NSTaggedPointerString
複製程式碼

這驗證了上文的所寫的。需要注意的是,現在我們把包含空位 0 的指標完整地列印出來,可以讓每次迭代的列印結果對比更加清晰。

現在對比一下第二次迭代的列印結果:

0x0000000000626125 ab NSTaggedPointerString
複製程式碼

可以看到最低 4 位沒有發生任何變化,這也在我們意料之中。這個十六進位制的數字 5 會一直保持不變,總是表明它是一個 NSTaggedPointerString 型別的 tagged pointer。

而原來的 61 也保持原來的位置,不過現在它前面出現了 62。顯而易見,62 是字母 b 的 ASCII 編碼,因此我們可以知道當前的編碼方式是使用 ASCII 的 8 位編碼。而在最低位之前的 4 位由 1 變成了 2,由此我們可以想到它或許表示了字串的長度。接下來的迭代確認了這個猜想:

0x0000000063626135 abc NSTaggedPointerString
0x0000006463626145 abcd NSTaggedPointerString
0x0000656463626155 abcde NSTaggedPointerString
0x0066656463626165 abcdef NSTaggedPointerString
0x6766656463626175 abcdefg NSTaggedPointerString
複製程式碼

按理來說,由於 tagged pointer 的空位已經填滿了,迭代應該也到此為止。可事實的確如此嗎?並不是!

0x0022038a01169585 abcdefgh NSTaggedPointerString
0x0880e28045a54195 abcdefghi NSTaggedPointerString
0x00007fd275800030 abcdefghij __NSCFString
複製程式碼

迴圈中的程式碼繼續執行下去,直到兩次迭代後才終止。表示長度的區間繼續保持增長,但指標剩餘的部分卻顯得雜亂無章。到底是發生了什麼呢?讓我們深入其實現程式碼來一探究竟。

刨根問底

NSTaggedPointer 類存在於 CoreFoundation 庫中。似乎把它放在 Foundation 中會更加合理一點,但實際上現在蘋果許多核心的 Objective-C 類都被移到了 CoreFoundation 當中,因為蘋果慢慢地放棄了把 CoreFoundation 變成一個獨立實體的想法。

先來看看 -[NSTaggedPointerString length] 的實現:

push       rbp
mov        rbp, rsp
shr        rdi, 0x4
and        rdi, 0xf
mov        rax, rdi
pop        rbp
ret
複製程式碼

Hopper 工具為我們提供了這個簡易的反編譯版本:

unsigned long long -[NSTaggedPointerString length](void * self, void * _cmd) {
    rax = self >> 0x4 & 0xf;
    return rax;
}
複製程式碼

簡單來說,提取出 4 至 7 位的值並返回它們便可以得到字串的長度。這證實了我們上文中所觀察到的 —— 在最低位的十六進位制 5 前面的 4 位表示了字串的長度。

NSString 子類中另一個原始方法是 characterAtIndex:。由於其彙編程式碼太長,我會直接跳過並給出 Hopper 反編譯出的可讀性較高的版本:

   unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long arg2) {
        rsi = _cmd;
        rdi = self;
        r13 = arg2
        r8 = ___stack_chk_guard;
        var_30 = *r8;
        r12 = rdi >> 0x4 & 0xf;
        if (r12 >= 0x8) {
                rbx = rdi >> 0x8;
                rcx = "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX";
                rdx = r12;
                if (r12 < 0xa) {
                        do {
                                *(int8_t *)(rbp + rdx + 0xffffffffffffffbf) = *(int8_t *)((rbx & 0x3f) + rcx);
                                rdx = rdx - 0x1;
                                rbx = rbx >> 0x6;
                        } while (rdx != 0x0);
                }
                else {
                        do {
                                *(int8_t *)(rbp + rdx + 0xffffffffffffffbf) = *(int8_t *)((rbx & 0x1f) + rcx);
                                rdx = rdx - 0x1;
                                rbx = rbx >> 0x5;
                        } while (rdx != 0x0);
                }
        }
        if (r12 <= r13) {
                rbx = r8;
                ___CFExceptionProem(rdi, rsi);
                [NSException raise:@"NSRangeException" format:@"%@: Index %lu out of bounds; string length %lu"];
                r8 = rbx;
        }
        rax = *(int8_t *)(rbp + r13 + 0xffffffffffffffc0) & 0xff;
        if (*r8 != var_30) {
                rax = __stack_chk_fail();
        }
        return rax;
    }
複製程式碼

我們稍微整理一下:前三行中,Hopper 讓我們知道了暫存器分別存放了哪些引數。我們馬上著手把 rsi 替換成 _cmd,然後把 rdi 替換成 selfarg2 實際上是 index 引數,因此我們把所有 r13 的呼叫替換成 index。接下來,由於 __stack_chk 其實是一個用來加強防禦性的東西,且它與函式的實際作用沒有多大關聯,我們可以暫時忽略掉它。現在整理過後的程式碼看起來大概是這個樣子的:

    unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long index) {
        r12 = self >> 0x4 & 0xf;
        if (r12 >= 0x8) {
                rbx = self >> 0x8;
                rcx = "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX";
                rdx = r12;
                if (r12 < 0xa) {
                        do {
                                *(int8_t *)(rbp + rdx + 0xffffffffffffffbf) = *(int8_t *)((rbx & 0x3f) + rcx);
                                rdx = rdx - 0x1;
                                rbx = rbx >> 0x6;
                        } while (rdx != 0x0);
                }
                else {
                        do {
                                *(int8_t *)(rbp + rdx + 0xffffffffffffffbf) = *(int8_t *)((rbx & 0x1f) + rcx);
                                rdx = rdx - 0x1;
                                rbx = rbx >> 0x5;
                        } while (rdx != 0x0);
                }
        }
        if (r12 <= index) {
                rbx = r8;
                ___CFExceptionProem(self, _cmd);
                [NSException raise:@"NSRangeException" format:@"%@: Index %lu out of bounds; string length %lu"];
                r8 = rbx;
        }
        rax = *(int8_t *)(rbp + index + 0xffffffffffffffc0) & 0xff;
        return rax;
    }
複製程式碼

注意第一個 if 語句之前的這行程式碼:

r12 = self >> 0x4 & 0xf
複製程式碼

我們可以發現,這正是我們前面所看到的 -length 實現程式碼。既然如此,那我們就把 r12 全部替換成 length

    unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long index) {
        length = self >> 0x4 & 0xf;
        if (length >= 0x8) {
                rbx = self >> 0x8;
                rcx = "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX";
                rdx = length;
                if (length < 0xa) {
                        do {
                                *(int8_t *)(rbp + rdx + 0xffffffffffffffbf) = *(int8_t *)((rbx & 0x3f) + rcx);
                                rdx = rdx - 0x1;
                                rbx = rbx >> 0x6;
                        } while (rdx != 0x0);
                }
                else {
                        do {
                                *(int8_t *)(rbp + rdx + 0xffffffffffffffbf) = *(int8_t *)((rbx & 0x1f) + rcx);
                                rdx = rdx - 0x1;
                                rbx = rbx >> 0x5;
                        } while (rdx != 0x0);
                }
        }
        if (length <= index) {
                rbx = r8;
                ___CFExceptionProem(self, _cmd);
                [NSException raise:@"NSRangeException" format:@"%@: Index %lu out of bounds; string length %lu"];
                r8 = rbx;
        }
        rax = *(int8_t *)(rbp + index + 0xffffffffffffffc0) & 0xff;
        return rax;
    }
複製程式碼

現在來看 if 語句內部的程式碼,第一行把 self 右移了 8 位。這 8 位是儲存了 tagged pointer 的指示符以及字串長度。而右移操作後得到的值,我們可以推測它就是其真正的資料。因此我們把 rbx 替換為 stringData 來讓程式碼更加清晰可讀一點。下一行把一個類似查詢表的東西賦值給 rcx,因此我們也把 rcx 替換成 table。最後,rdx 拿到了值的長度的一份拷貝。看起來它後面會作為游標來使用,因此我們再把 rdx 替換為 cursor。現在我們的程式碼是這樣的:

    unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long index) {
        length = self >> 0x4 & 0xf;
        if (length >= 0x8) {
                stringData = self >> 0x8;
                table = "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX";
                cursor = length;
                if (length < 0xa) {
                        do {
                                *(int8_t *)(rbp + cursor + 0xffffffffffffffbf) = *(int8_t *)((stringData & 0x3f) + table);
                                cursor = cursor - 0x1;
                                stringData = stringData >> 0x6;
                        } while (cursor != 0x0);
                }
                else {
                        do {
                                *(int8_t *)(rbp + cursor + 0xffffffffffffffbf) = *(int8_t *)((stringData & 0x1f) + table);
                                cursor = cursor - 0x1;
                                stringData = stringData >> 0x5;
                        } while (cursor != 0x0);
                }
        }
        if (length <= index) {
                rbx = r8;
                ___CFExceptionProem(self, _cmd);
                [NSException raise:@"NSRangeException" format:@"%@: Index %lu out of bounds; string length %lu"];
                r8 = rbx;
        }
        rax = *(int8_t *)(rbp + index + 0xffffffffffffffc0) & 0xff;
        return rax;
    }
複製程式碼

現在,程式碼基本上已經完全符號化了,不過還有一個暫存器名稱仍然存在:rbp。它實際上是幀指標。因此,編譯器其實做了一些很 tricky 的事情 —— 直接通過幀指標進行了索引操作。二進位制補碼中有一條原理: “所有東西最終都是無符號整型” ,因此為了讓變數減去 65,我們可以將其與 0xffffffffffffffbf 常量相加。接下來,它又減去了 64(倒數第二行程式碼)。這兩個值大概都是分配在棧上的區域性變數。仔細看的話,你會發現這段程式碼非常奇怪 —— 有一條路徑是隻進行了讀操作而沒有進行任何的寫操作。這到底是怎麼回事呢?

原因其實是 Hopper 忘記了對另一個 if 條件判斷的 else 分支進行反編譯。相對應的彙編程式碼看起來是這樣的:

mov        rax, rdi
shr        rax, 0x8
mov        qword [ss:rbp+var_40], rax
複製程式碼

var_40 便是在 Hopper 反編譯版本中的偏移量 6440 剛好是 64 的十六進位制表示)。我們暫且把這個位置的指標稱為 buffer。這樣一來,上面程式碼中遺漏分支的 C 語言程式碼看起來是這樣的:

*(uint64_t *)buffer = self >> 8
複製程式碼

現在把這段程式碼插入原本的程式碼中,並替換掉其他位置的 rbpbuffer。最後,為了能提醒自己,我們在函式開始的位置再補上一行 buffer 的宣告語句:

    unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long index) {
        int8_t buffer[11];
        length = self >> 0x4 & 0xf;
        if (length >= 0x8) {
                stringData = self >> 0x8;
                table = "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX";
                cursor = length;
                if (length < 0xa) {
                        do {
                                *(int8_t *)(buffer + cursor - 1) = *(int8_t *)((stringData & 0x3f) + table);
                                cursor = cursor - 0x1;
                                stringData = stringData >> 0x6;
                        } while (cursor != 0x0);
                }
                else {
                        do {
                                *(int8_t *)(buffer + cursor - 1) = *(int8_t *)((stringData & 0x1f) + table);
                                cursor = cursor - 0x1;
                                stringData = stringData >> 0x5;
                        } while (cursor != 0x0);
                }
        } else {
            *(uint64_t *)buffer = self >> 8;
        }
        if (length <= index) {
                rbx = r8;
                ___CFExceptionProem(self, _cmd);
                [NSException raise:@"NSRangeException" format:@"%@: Index %lu out of bounds; string length %lu"];
                r8 = rbx;
        }
        rax = *(int8_t *)(buffer + index) & 0xff;
        return rax;
    }
複製程式碼

現在程式碼看起來好了很多。不過那些瘋狂的指標操作語句實在是太難看懂了,而它們僅僅是一些陣列的索引操作。我們來簡化一下:

   unsigned short -[NSTaggedPointerString characterAtIndex:](void * self, void * _cmd, unsigned long long index) {
        int8_t buffer[11];
        length = self >> 0x4 & 0xf;
        if (length >= 0x8) {
                stringData = self >> 0x8;
                table = "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX";
                cursor = length;
                if (length < 0xa) {
                        do {
                                buffer[cursor - 1] = table[stringData & 0x3f];
                                cursor = cursor - 0x1;
                                stringData = stringData >> 0x6;
                        } while (cursor != 0x0);
                }
                else {
                        do {
                                buffer[cursor - 1] = table[stringData & 0x1f];
                                cursor = cursor - 0x1;
                                stringData = stringData >> 0x5;
                        } while (cursor != 0x0);
                }
        } else {
            *(uint64_t *)buffer = self >> 8;
        }
        if (length <= index) {
                rbx = r8;
                ___CFExceptionProem(self, _cmd);
                [NSException raise:@"NSRangeException" format:@"%@: Index %lu out of bounds; string length %lu"];
                r8 = rbx;
        }
        rax = buffer[index];
        return rax;
    }

複製程式碼

現在,我們能看出些端倪了。

首先可以看到基於長度的不同,會有三種不同的情況。長度小於 8 的情況下,會執行到剛剛我們補充的遺漏分支 —— 單純地把 self 的值按位移動後賦給 buffer。這是簡單 ASCII 的情況。在此情況下,index 只作為 self 的索引值來取出給定位元組,並隨後返回給呼叫方。由於 ASCII 字元的值在指定範圍內可以匹配 Unicode 碼位。因此不需要額外的操作便可以返回正確結果。我們在上文曾猜測這種情況下(長度小於 8)會直接存放 ASCII 碼,這也驗證了我們的猜想。

那麼在長度大於或等於 8 的情況下會怎麼樣呢?如果字串長度等於 8 或大於 8 且小於 10,那麼會執行一段迴圈程式碼:首先取出 stringData 的最低 6 位,然後作為 table 的索引並取出相應的值,再拷貝到 buffer 中。接下來,會把 stringData 右移 6 位然後重複上面的操作直到遍歷完整個字串。這段程式碼其實是一種 6 位編碼方式 —— 通過原始 6 位資料在 table 中的索引進行編碼。buffer 中會構建出一個臨時的字串,然後最後的索引操作(上面函式中倒數第二行)會取出其需要的字元。

當如果長度大於 10 呢?可以看到其程式碼和長度在 8 到 10 的情況下基本一致,除了現在一次只處理 5 位而不是 6 位資料。這是一種更加緊湊的編碼方式,可以讓 tagged pointer 字串最多能夠儲存 11 個字元,不過它使用的字母表僅包含 32 個值(僅使用了 table 的前半部分)。

因此,我們可以得到 tagged pointer 字串的結構大致為:

  1. 長度在 0 到 7 範圍內時,直接儲存原始的 8 位字元。
  2. 長度為 8 或者 9時, 儲存 6 位編碼後的字元,編碼使用的字母表為 "eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX"
  3. 長度大於 10 時,儲存 5 位編碼後的字元,編碼使用的字母表為 "eilotrm.apdnsIc ufkMShjTRxgC4013"

現在我們來對比一下前面生成的資料:

0x0000000000006115 a NSTaggedPointerString
0x0000000000626125 ab NSTaggedPointerString
0x0000000063626135 abc NSTaggedPointerString
0x0000006463626145 abcd NSTaggedPointerString
0x0000656463626155 abcde NSTaggedPointerString
0x0066656463626165 abcdef NSTaggedPointerString
0x6766656463626175 abcdefg NSTaggedPointerString
0x0022038a01169585 abcdefgh NSTaggedPointerString
0x0880e28045a54195 abcdefghi NSTaggedPointerString
0x00007fbad9512010 abcdefghij __NSCFString
複製程式碼

0x0022038a01169585 的二進位制表示式去掉了字串後面的 8 位,並把剩餘的位分成了 6 位為單位的塊:

001000 100000 001110 001010 000000 010001 011010 010101
複製程式碼

以這些數值作為 table 的索引,我們的確可以拼出 "abcdefgh"。同樣的,0x0880e28045a54195 的二進位制表示式也有類似的規則:

001000 100000 001110 001010 000000 010001 011010 010101 000001
複製程式碼

可以看到它與上面的字串基本一致,只是在最後多出了字元 i

但是後面的字串卻不符合我們的預測。在這之後,按理來說本應會切換為 5 位編碼方式進行儲存,且在兩個字串之後才會終止。但事實卻是,在長度等於 10 的時候 tagged pointer 字串就已經停止了工作,並轉為建立真正的字串物件,為什麼會這樣?

原因是 5 位編碼方式中使用的字母表過於受限,因此沒有包含字母 b!相對於 5 位編碼方式使用的字母表中 32 個 “神聖” 的字元而言,b 這個字母的普遍程度肯定還不夠,以至於未能取得其中的一席之地。既然如此,我們換成從 c 開始的字串再嘗試一次,列印結果如下:

0x0000000000006315 c NSTaggedPointerString
0x0000000000646325 cd NSTaggedPointerString
0x0000000065646335 cde NSTaggedPointerString
0x0000006665646345 cdef NSTaggedPointerString
0x0000676665646355 cdefg NSTaggedPointerString
0x0068676665646365 cdefgh NSTaggedPointerString
0x6968676665646375 cdefghi NSTaggedPointerString
0x0038a01169505685 cdefghij NSTaggedPointerString
0x0e28045a54159295 cdefghijk NSTaggedPointerString
0x01ca047550da42a5 cdefghijkl NSTaggedPointerString
0x39408eaa1b4846b5 cdefghijklm NSTaggedPointerString
0x00007fbd6a511760 cdefghijklmn __NSCFString
複製程式碼

現在,我們獲得了一直到長度為 11 的所有 tagged pointer 字串。最後兩個 tagged pointer 字串的二進位制表示如下:

01110 01010 00000 10001 11010 10101 00001 10110 10010 00010
01110 01010 00000 10001 11010 10101 00001 10110 10010 00010 00110
複製程式碼

正是我們預期的 5 位編碼。

構造 Tagged Pointer 字串

既然我們現在知道了 tagged pointer 字串是如何編碼的,那麼我就不再深入地探究其構造方法的實現了。構造的程式碼在一個名為 __CFStringCreateImmutableFunnel3 的私有函式內。這個巨大的函式包含了所有可能的情況。你可以在 opensource.apple.com 中提供的 CoreFoundation 開源版本中找到此函式,不過別高興太早:開源版本並沒有包含關於 tagged pointer 字串的程式碼實現。

構造 tagged pointer 字串的程式碼實際上是我們上面看到的程式碼的相反版本。如果 tagged pointer 字串能夠容下原始字串的長度和內容,那麼就會開始一點一點地構造 —— 包含 ASCII 字元、6 位或者 5 位編碼的字元。其中會有一個相反的查詢表。上文程式碼中看到的字元常量的查詢表是一個名為 sixBitToCharLookup 的全域性變數,在 Funnel3 函式中有一個與之相對應的 sixBitToCharLookup 變數。

奇怪的查詢表

完整的 6 位編碼查詢表如下:

eilotrm.apdnsIc ufkMShjTRxgC4013bDNvwyUL2O856P-B79AFKEWV_zGJ/HYX
複製程式碼

可能大家都會很自然地問一個問題:為什麼它的順序如此奇怪?

因為這個表同時提供給 6 位編碼和 5 位編碼使用,這就是為什麼它和普通的英語字母表有同樣的順序。那些使用頻率非常高的字元位於表的前半段,而那些使用頻率沒那麼高的字串就會放在後半段。這在最大程度上保證了稍長的字串能夠使用 5 位編碼。

雖然如此,假如我們把查詢表對半分,每一半中的順序彷彿顯得無關緊要。對半分後的表按理來說可以按照英語字母表的順序進行排序,但事實卻並非如此。

查詢表開頭位置的幾個字母似乎是按照其在英語中出現的頻率排序的。英語中最常見的字母是 E,其實是 T,接下來依次為 A、O、I、N 和 S。E 確實位於表的開頭,且剩下的幾個字母也的確都位於靠近開頭的位置。看起來這張查詢表的確是按照使用頻率進行排序的。至於其為什麼體現出和英語的差異性,大概是因為在 Cocoa 應用中的短字串並不是隨機選自英文散文,而是一些較為特別的語言。

我猜測蘋果原本打算使用一種更為巧妙的變長編碼方式,或許會基於一種 Huffman 編碼。但最後發現其實現難度太大,或者價效比並沒有想象中高,甚至是因為時間不允許的原因。因此他們決定退而求其次,使用一種更容易實現的版本,也就是我們上文中所看到的編碼方式 —— 針對不同長度的字串使用不同的定長編碼方式(每個字元 8 位、6 位或 5 位)。這個奇怪的查詢表也許是基於被遺棄的變長編碼方式而構建的,同時也便於日後決定再次啟用變長編碼方式。雖然這些純屬猜測,但至少我感覺事實便是如此。

總結

Tagged pointer 是一種非常酷的技術。雖然應用到字串上面並不常見,但很清楚的是,蘋果肯定從中受益良多,不然也不會對它傾注如此多精力和想法。能夠看到這兩種技術融合在一起的效果,以及它們如何對有限的儲存空間物盡其用,實在是有趣至極。

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg

相關文章