開發者應認識的資料型態及效用函式


iThome 網站首載:開發者應認識的資料型態及效用函式

程式經常面對資料處理問題。不同語言提供不同核心型態及效用(Utility)函式,雖然分別有其擅長處理的資料類型,但這些類型有不少共通處理模式。作為一個通用型語言,其核心型態語法或效用函式,若能提供支援資料共通處理模式的實作,對於程式撰寫非但能增進效率,更能具備啟發的作用。身為開發者,亦應對這些資料型態或效用函式有所認識,除了能善加利用之外,在語言不支援時,亦可從第三方程式庫採用或自行實作類似特性。

實用資料型態與建立方式

對現代主流語言來說,基本上都會提供三種資料結構的實作,分別是有序陣列或數列(List)、不重複集合(Set)以及鍵值結構的對應(Map)、字典(Dictionary)、雜湊(Hash)或關聯陣列(Associative array)。有經驗的開發者,對這些基本資料結構的特性與使用絕不陌生。不過有個並非語言都會具備,但非常實用的是範圍(Range)物件,只要指定起點、終點,就能在程式中逐一列舉指定範圍的所有資料,資料不一定是數字,也可以是字元或甚至遵守某列舉協定(Protocol)的一組資料。像是Scala、Haskell、Ruby等語言,都在語法層面直接支援範圍物件,Python則是透過range函式傳回類別range的實例。

數列、集合或關聯陣列式的結構由於使用頻繁,以簡潔為訴求特性之一的動態語言,往往會提供實字方式來建立相關實例。例如在Python、Ruby、JavaScript等中,可以[1, 2, 3]方式來建立有序數列或陣列。對於不提供實字建立方式的靜態語言,可使用工廠方法加以封裝,例如Java可以Arrays.asList來建立List實作物件,或者如Google Guava中以of方法提供對應的Set、Map等不可變動結構的實作物件。基於簡潔,JDK8中亦預計引入相關實字語法,舉例來說,以下建立分別建立不可變動的List、Set與Map實作物件:
List<Integer> piDigits = [3, 1, 4, 1, 5, 9];
Set<Integer> primes = {2, 7, 31, 127, 8191};
Map<Integer, String> platonicSolids = {4 : "tetrahedron", 6 : "cube"};

對於陣列或數列資料的初始,語言可能會有其預設值,然而多半情況下,預設值不會符合需求。Python或Ruby中提供一個不錯的特性,[10] * 5會建立[10, 10, 10, 10, 10]。若語言本身沒有支援,可透過工廠方法或函式封裝,例如Java中可使用Arrays.fill或Collections.fill將一個已存在的陣列或數列填滿想要的初值,Scala的List.fill亦有類似作用。

實用的切割、串接與鍊扣函式

經常地,必須將數列切割為數個子數列,通常語言會提供slice之類名稱的效用函式來達到此目的,在Java中則是由List的subList方法來提供。切割時通常是以指定索引範圍為依據。要留意的是,程式庫提供的切割方法,是否會對原數列作了變動,比方說,Ruby分別提供了slice或slice!方法,前者不會對原數列變動,後者在切出子數列後,原數列就不包括子數列。基於簡潔,有些語言會在語法上直接支援數列切割,像是Python中可以list[3:9]將索引3至索引9前的元素切割出來,對於程式撰寫效率上有非常大的幫助。

有些切割範圍經常進行,slice方法若能在起始或結束索引從缺時提供預設值,會對程式簡潔有所幫助。例如Python可以list[5:]將索引5開始至尾端的子數列切分出來。除了提供從缺索引的預設值之外,可提供語意更清楚的方法來封裝slice的動作,例如list.slice(0, x)是個經常進行的切割模式,其實就是取數列前x項,因此可撰寫list.take(x)方法來取代,list.slice(x, list.size)實際就是拋棄數列前x項,因此可用list.drop(x)來取代。實際上,slice可以由take、drop實作出來,例如Haskell本身就沒有提供slice方法,但可以用take、drop組合而成,例如slice from to = take (to - from) . drop from

除了以明確的take、drop取代slice之外,亦可以list.head取代list.get(0)。將第一個元素去掉,只留下剩下的尾端數列亦為常見,因此可以list.tail取代。與head相反的則是last,作用為取數列最後一個元素,奇怪的是,這是個經常需要的動作,但不少程式庫缺少此方法,像是Java的List預設就沒有定義getLast方法。與tail相反的則是取不包括最後元素的子數列,通常命名為init。last與init在反向處理一組資料時會很有用。

兩個數列的串接是常見需求,因此程式庫多會提供append或concat函式,同樣地,必須注意是否對原數列作了變動。通常變動原數列的動作,會以方法或函式呈現,而對於產生新數列作為兩數列串接成果的需求來說,若能提供簡便的語法,在語意上也會更清楚,像是Python的數列串接就可以是[1, 2, 3] + [4, 5, 6]。另一個經常使用的是鍊扣函式,名稱通常取名為zip,正如其名稱,它將兩個數列看作是拉鍊,其作用就是將來自兩數列的元素兩兩配對,成為一個新數列。當你要同時處理兩個數列時,先用zip將它們扣合在一起,處理上會方便許多
   
數列通用處理模式

處理一組資料經常需要重複性動作,其中最常出現的三個處理模式就是映射(Map)、過濾(Filter)與折合(Fold)。映射是指定處理方式將某組資料映射為另一組資料;過濾是指定篩選條件以留下符合條件的一組元素;折合是較抽象的流程概念,就作用而言,是指定動作從一組資料中計算出單一值,例如計算一組字串的長度加總,之所以稱為折合,是因為就流程概念而言,就像將元素逐一寫在紙上,從左而右或從右而左開始折疊,每次重疊都取當次折合元素與上次折合成果作指定運算,在所有元素都折疊完畢後得到結果。有些語言會將折合概念的效用函式命名為reduce

許多資料處理問題,都是由這三種操作搭配組合就可獲得解決。在進行資料處理前,優先以映射、過濾與折合來思考可否解決問題,不僅可避免撰寫類似結構的重複流程,程式庫的實作通常也較有效率,更重要的是,可因此得到對問題的不同思考方向,像是簡化的子問題處理,更清楚的邊界條件等。如果語言本身具備一級函式(First-class)特性,在程式語意上也會更為簡潔與清晰。

實際上有些語言提供有數列表達式(List comprehension)語法,可用比較簡單的語法來作到映射、過濾。例如Python中要將所有元素加1,可以[ele + 1 for ele in list],要將大於3的元素過濾出來,可以[ele for ele in list if ele > 3]。基本上映射、過濾、折合作得到的,數列表達式都作得到,因此像Ruby就沒有直接提供類似數列表達式的語法,但有時數列表達式作得到的,使用映射、過濾與折合來實作可能會囉嗦點。例如一個情況是巢狀數列表達式[(i, j) for i in [1, 2, 3] for j in [4, 5, 6]],要產生相同的結果,不使用數列表達式就得在映射後進行折合的處理。

不過數列表達式也不只是為了簡潔而設立的語法,某些情況下,它的樣子會像是數學表達式。例如數學中1到100的正整數中,由2倍數組成的數列可表達為{2 * x | x ∈ {1, 2..100}},若用Haskell的數列表達式來寫,會是[2 * x | x <- [1, 2..100]],跟原本的數學定義真的是蠻像的。既便不使用Haskell,在具有數列表達式的語言中,從數學表達式的方向來思考此一語法目的,也會具有啟發不同解題方式的作用。

數列處理模式延伸出來的函式

映射、過濾與折合是處理一組資料時的通用模式,針對特定需求可以將它們的職責細化。例如若想要一組資料中任一元素符合條件就傳回真值,或者所有資料都符合條件時才傳回真值,雖可折合來實作,但若可分別實作出any或all函式,語意上會清楚許多。類似地,若想從清單中擷取或拋棄某條件失敗前的子清單,結合折合與過濾可以實作得出來,但若可以分別實作出takeWhile或dropWhile之類的效用函式,對於程式撰寫時的效率也會有不少的幫助。