本文描述了一個最近包含在我的Range-v3庫中的巧妙方法:一個具有精煉語法的類似Python切片的工具。從功能的角度來看,這並沒有什麼驚天動地的,但在庫設計中,卻是一個有趣的小案例,同時,它也很好地說明了我的庫設計理念。
在Python中,切分容器,也即是創建一個連續子域的視圖,它使用一個非常簡潔的語法,比如:
>>> 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使用智能缺省:
>>> # 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']
你甚至可以使用負的偏移量從末尾切片:
>>> # Take the last two elements: >>> letters[-2:]
這都很酷很方便。
我的range-v3庫已經實現切片操作很久了,但它不是那麼強大,語法也不太酷:
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不接受負的偏移量以實現從末尾切片。所以無論如何也並不強大。
首先,我想找到一個創建切片的精煉形式,所以我借鑒了array_view提議,此提議有一個真正的智能語法來索引多維數組。下面是提議中給出的一個例子:
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)的元素。這究竟是什麼呢?
其實很簡單:對統一初始化語法的一個新的使用而已。考慮如下類型:
struct indices { std::size_t i, j, k; }; struct my_array_view { double & operator[](indices x); };
現在我便可以使用av[{0,0,0}]語法來索引my_array_view對象,多麼簡潔!
我意識到我可以使用這個技巧,給大家提供一個超級短小精悍的域切片語法。
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個元素中的最後一個。
換句話說,下面的代碼明顯不合理:
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返回的輸入流在編譯階段知道它不能從末尾切片。但是偏移量是正值還是負值卻是一個運行時的屬性,因此在編譯階段不能檢測。這將造成運行時出錯,呃。
更糟糕的是,關於哪一類域可以接受負的偏移量的規則出奇的微妙。考慮上面的代碼的這種變化:
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)大小的一個域是類型系統的一部分時,便開啟各種優化。如果我們直到運行時才知道偏移的符號,我們不能再返回一個標榜自己為已知大小的類型。
我的意思是,對於什麼時候可以從末尾進行切片的規則是微妙的,過於微妙以至於直到運行時才會報告錯誤,而且這樣做使得寶貴的優化變得一文不值。
我想出的一個解決辦法是,使用一個無條件的斷言來禁止負的偏移。但是在你對我感到氣憤前,請稍等一下。我添加了一個替代語法來表示一個從末尾開始的偏移。看看這個:
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,右邊是一個整型數據。這樣返回的某種類型的對象可以使得從末尾開始的偏移成為類型系統的一部分。
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>:
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)的操作。我現在還不確定切片與索引是否顯著不同,所以我的決定不一定正確。歡迎大家給出自己的想法。
--------------------------------------分割線 --------------------------------------
CentOS上源碼安裝Python3.4 http://www.linuxidc.com/Linux/2015-01/111870.htm
《Python核心編程 第二版》.(Wesley J. Chun ).[高清PDF中文版] http://www.linuxidc.com/Linux/2013-06/85425.htm
《Python開發技術詳解》.( 周偉,宗傑).[高清PDF掃描版+隨書視頻+代碼] http://www.linuxidc.com/Linux/2013-11/92693.htm
Python腳本獲取Linux系統信息 http://www.linuxidc.com/Linux/2013-08/88531.htm
在Ubuntu下用Python搭建桌面算法交易研究環境 http://www.linuxidc.com/Linux/2013-11/92534.htm
Python 語言的發展簡史 http://www.linuxidc.com/Linux/2014-09/107206.htm
Python 的詳細介紹:請點這裡
Python 的下載地址:請點這裡