在C++中實現Python切片
本文描述了一個最近包含在我的Range-v3庫中的巧妙方法:一個具有精煉語法的類似Python切片的工具。從功能的角度來看,這並沒有什麼驚天動地的,但在庫設計中,卻是一個有趣的小案例,同時,它也很好地說明了我的庫設計理念。
Python切片
在Python中,切分容器,也即是建立一個連續子域的檢視,它使用一個非常簡潔的語法,比如:
1 2 3 4 5 6 7 8 9 10 |
>>> letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] >>> letters ['a', 'b', 'c', 'd', 'e', 'f', 'g'] >>> # access a subrange with a slice operation >>> letters[2:5] ['c', 'd', 'e'] >>> # replace some values >>> letters[2:5] = ['C', 'D', 'E'] >>> letters ['a', 'b', 'C', 'D', 'E', 'f', 'g'] |
第5行,我們使用語法letters[2,5)得到列表letters在半開區間[2,5)中的元素。簡潔明瞭。第8行,我們使用切片賦值,最終改變了列表letters。如此證明Python切片具有引用的語義。
Python切片能做到的還不止這些。你可以省略偏移量,讓Python使用智慧預設:
1 2 3 4 5 6 |
>>> # A missing first offset means "from the beginning" >>> letters[:5] ['a','b','C', 'D', 'E'] >>> # A missing end offset means "to the end" >>> letters[5:] ['f','g'] |
你甚至可以使用負的偏移量從末尾切片:
1 2 |
>>> # Take the last two elements: >>> letters[-2:] |
這都很酷很方便。
Range-v3中C++實現的舊式切片
我的range-v3庫已經實現切片操作很久了,但它不是那麼強大,語法也不太酷:
1 2 3 4 5 6 |
using namespace ranges; auto letters = view::iota('a','g'); std::cout << letters << 'n'; // prints: {a,b,c,d,e,f,g} std::cout << (letters | view::slice(2,5)) << 'n'; // prints: {c,d,e} |
上面程式碼中,view:iota 是一個生成從’a’到’g’(包含’g’)的字元檢視,view::slice是一個從偏移量2到偏移量5(不包含5)的元素檢視。與Python的切片相比,這種切片是輕量、非持有的。
這種語法本身並不糟糕,但肯定沒有Python的有趣。而且view:slice不接受負的偏移量以實現從末尾切片。所以無論如何也並不強大。
Range-v3中C++實現的新式切片
首先,我想找到一個建立切片的精煉形式,所以我借鑑了array_view提議,此提議有一個真正的智慧語法來索引多維陣列。下面是提議中給出的一個例子:
1 2 3 4 5 |
char a[3][1][4] {{{'H', 'i'}}}; auto av = array_view<char, 3>{a}; // the following assertions hold: assert((av.bounds() == bounds<3>{3, 1, 4})); assert((av[{0, 0, 0}] == 'H')); |
第1-2行宣告瞭一個3-D字元陣列,然後建立它的3-D檢視。第5行是奇蹟發生的地方,它使用一個看起來有點陌生的語法av[{0,0,0}]訪問在位置(0,0,0)的元素。這究竟是什麼呢?
其實很簡單:對統一初始化語法的一個新的使用而已。考慮如下型別:
1 2 3 4 5 6 7 8 |
struct indices { std::size_t i, j, k; }; struct my_array_view { double & operator[](indices x); }; |
現在我便可以使用av[{0,0,0}]語法來索引my_array_view物件,多麼簡潔!
我意識到我可以使用這個技巧,給大家提供一個超級短小精悍的域切片語法。
1 2 3 4 5 6 |
using namespace ranges; auto letters = view::iota('a','g'); std::cout << letters << 'n'; // prints: {a,b,c,d,e,f,g} std::cout << letters[{2,5}] << 'n'; // prints: {c,d,e} |
嘿,這還不錯!
從末尾切片—一個困境
那還不夠充分,我想要方便的從末尾切片的功能。但是從庫的設計觀點來看,這裡變得有點趣味。並不是所有的域型別(range types)支援從末尾切片。要明白我的意思,你可以考慮一個從istream讀入的ints域。這是一個輸出域。直到得到它你才能知道它的末尾,也就意味著直到N個元素傳遞給它你才能知道N個元素中的最後一個。
換句話說,下面的程式碼明顯不合理:
1 2 3 4 5 |
using namespace ranges; // An input range of ints read from cin auto ints = istream<int>(std::cin); // I'm sorry, I can't do that, Dave: std::cout << ints[{0,-2}] << 'n'; |
istream返回的輸入流在編譯階段知道它不能從末尾切片。但是偏移量是正值還是負值卻是一個執行時的屬性,因此在編譯階段不能檢測。這將造成執行時出錯,呃。
更糟糕的是,關於哪一類域可以接受負的偏移量的規則出奇的微妙。考慮上面的程式碼的這種變化:
1 2 3 4 5 |
using namespace ranges; // Take the first 10 ints read from cin: auto ints = istream<int>(std::cin) | view::take(10); // This should work! It should take the first 8 ints: std::cout << ints[{0,-2}] << 'n'; |
這裡例子中,我們已經從輸入流中取出10個整數。Ints域依然是一個輸入域,但是是一個具有確定大小的輸入域。現在我們可以從末尾開始切片了,因為我們知道它的末尾的位置。
如果我們有一個已知的域,我們總是可以從末尾切片,儘管我們不知道末尾在哪(例如:一個null結尾的字串),通過計算序列的長度,然後從前面開始推進distance減N個元素(雖然這並不總是最有效的方法來實現這一點)。
如果域是無窮大的,你永遠不要指定一個負的偏移量,永遠,永遠不要。
還可以變得更加微妙:如果兩個偏移量都是負的,或者兩個偏移量都是非負的,則所得到的切片在O(1)內得到它的大小。否則,它只能在被切片的域知道自身大小的情況下得到它的大小。當O(1)大小的一個域是型別系統的一部分時,便開啟各種優化。如果我們直到執行時才知道偏移的符號,我們不能再返回一個標榜自己為已知大小的型別。
我的意思是,對於什麼時候可以從末尾進行切片的規則是微妙的,過於微妙以至於直到執行時才會報告錯誤,而且這樣做使得寶貴的優化變得一文不值。
從末尾切片—一種方法
我想出的一個解決辦法是,使用一個無條件的斷言來禁止負的偏移。但是在你對我感到氣憤前,請稍等一下。我新增了一個替代語法來表示一個從末尾開始的偏移。看看這個:
1 2 3 4 5 6 |
using namespace ranges; auto letters = view::iota('a','g'); std::cout << letters << 'n'; // prints: {a,b,c,d,e,f,g} std::cout << letters[{2,end-2}] << 'n'; // prints: {c,d,e} |
我們用end-2表示距離末尾的第二個元素,替代使用一個負的偏移,這裡的end是什麼呢?它是一個你可以呼叫的end函式,用來獲得一個迭代的結尾(想想std::end),只有在我的庫中它不是一個函式,而是一個函式物件。(瞭解更多我為什麼選擇把begin和end當做全域性的函式物件,而不是簡單的函式,可以看下我的這篇定製設計的要點)。由於end 是一個物件,我便可以定義一個過載運算子operator-,這個運算子的左邊是end,右邊是一個整型資料。這樣返回的某種型別的物件可以使得從末尾開始的偏移成為型別系統的一部分。
1 2 3 4 5 6 7 |
struct from_end { int i; }; from_end operator-( decltype(ranges::end), int i ) { assert(i >= 0); // No funny business, please return {i}; } |
現在我可以在我的域型別上定義一個過載運算子operator[],運算子接收一個std:pair<int,from_end>:
1 2 3 4 5 6 7 8 9 |
struct my_range { // callable as rng[{2,end-2}] slice_view<my_range> operator[](std::pair<int, from_end> p) { // ... slicing happens here } }; |
瞧!現在得到一個從末尾開始的一個切片,我可以用一個簡短、可讀的語法和編譯時的型別檢測,而且不會因此錯過任何優化機會。
好的,但是
這樣很好,但是像”rng[{2,-2}]”這樣的程式碼仍然會編譯,並且在執行時出錯。怎樣讓這種情況變好點呢?現在的差異是傳遞一個負偏移去切片總是造成一個執行時的錯誤。即使設想域型別支援負偏移操作,但這麼做也不可能成功滿足你的願望。使用者很快會發現這不是正確的處理方法。
如果我們允許負的偏移有時有效,有時無效,這樣會使介面變得更加危險。使用者可能在嘗試中得到正確結果,然後便錯誤地得出這樣總是有效的結論。他們在部署應用之後將很難發現他們的錯誤。
它引出了我的庫的設計哲學:
I can’t keep people from writing bad code. But I’m guilty of collusion if I make it easy.
我不能阻止人們寫出糟糕的程式碼,但是如果我使他們很容易做成那樣的事情,我就是同謀。
關於這個問題的一個推論:
If you can’t make something succeed consistently, it’s better to make it fail consistently.
如果你不能使得某些事情一直成功,那最後讓它一直失敗。
希望你能喜歡這個庫設計中的小案例的學習。
致謝
感謝錢德勒-卡魯斯引導我注意Python切片操作的簡潔、出色。
腳註:
在C++容器中,索引操作只允許隨機存取容器,即元素可以在O(1)記憶體取。這裡,我允許使用者使用一個類似索引的符號進行域的切片,儘管可能是一個O(N)的操作。我現在還不確定切片與索引是否顯著不同,所以我的決定不一定正確。歡迎大家給出自己的想法。