1. 程式人生 > >Scala函數語言程式設計(四)函式式的資料結構 上

Scala函數語言程式設計(四)函式式的資料結構 上

這次來說說函式式的資料結構是什麼樣子的,本章會先用一個list來舉例子說明,最後給出一個Tree資料結構的練習,放在公眾號裡面,練習裡面給出了基本的結構,但程式碼是空缺的需要補上,此外還有預留的testcase可以驗證。

關注公眾號:哈爾的資料城堡,回覆“函式式資料結構”可以獲得。(寫文章不容易,大哥大姐關注下吧[哭笑])

然後是這系列的索引:

Scala函數語言程式設計指南(一) 函式式思想介紹

scala函數語言程式設計(二) scala基礎語法介紹

Scala函數語言程式設計(三) scala集合和函式

1.什麼是函式式的資料結構

還記得前面說過,函數語言程式設計最大的特點是什麼嗎?就是沒有副作用。那麼函式式的資料結構自然也是如此。

無副作用的關鍵是:

  1. 一個函式無論呼叫多少次,只要輸入引數相同,則結果也必然相同。
  2. 且這個函式執行過程中不會改變程式的任何外部狀態,如全域性變數,物件的屬性等。
  3. 函式的結果也不依賴外部狀態。

在java中,最經典的資料結構ArrayList,是通過一個全域性的size變數,來控制ArrayList的大小的,這就說明ArrayList並非無副作用。

在scala中,集合(List,Map等)預設是不可變的,以連結串列List為例,是無法通過push等操作,往一個連結串列裡面新增內容的。只能通過兩個連結串列相加的方式,生成一個新連結串列(Map也是一樣,通過兩個Map相加,Key相同的會覆蓋,以達到更新的目的)。這點倒是和String有點像。

不過其實這樣有一個問題,那就是很耗費記憶體。但這個問題可以用懶載入來解決,限於篇幅,後面再介紹吧。

總結一下,函式式的資料結構,最大的特點,就是沒有副作用。那麼如何實現無副作用的資料結構呢,我們下面用連結串列的例子來展示。

不過在這之前,需要先回顧下一些語法知識。

2.scala知識回顧

我的一個觀點是,語言的語法知識如果只是看,背,而沒有實際用到,那是比較難記住的。這裡就把這次會用到的語法知識做個簡單介紹,如果有需要,可以查閱前面寫的前兩章。

我這裡也有演示如果運用前面介紹的語法知識實現一個函式式的List()。

PS:如果不想看語法知識可以直接跳到第三節。

前面的語法索引:

scala函數語言程式設計(二) scala基礎語法介紹

Scala函數語言程式設計(三) scala集合和函式

2.1 scala的模式匹配

模式匹配類似於swtch語法,不過它能匹配的不止是值,還有資料型別。同時,它是一個匿名函式,在scala裡,函式不用return,能直接返回值。

val times = 1

//使用模式匹配來匹配值
times match {
  case 1 => "one"
  case 2 => "two"
  case _ => "some other number"
}

//使用模式匹配,匹配型別,再判斷值

times match {
  case i:Int if i == 1 => "one"
  case i:Int if i == 2 => "two"
  case _ => "some other number"
}

如果有小夥伴想了解更多,可以看看我這篇,scala模式匹配詳細解析。

2.2 object和apply

前面介紹到,object是一個類的伴生物件,而且相當於static類,記憶體裡只能有一個物件。apply方法則是說,可以在使用object物件的時候,直接預設使用。別說了,看程式碼:

scala> class Foo {}
defined class Foo

//有一個apply方法
scala> object FooMaker {
     |   def apply() = new Foo
     | }
defined module FooMaker

//新建object,自動得就呼叫了apply
scala> val newFoo = FooMaker() //賦值的物件是Foo,因為呼叫了FooMaker()的apply 
newFoo: Foo = Foo@5b83f762  

上面的程式碼,FooMaker相當於一個工廠。

2.3 scala的泛型

scala中的泛型,叫做型變或變性,英文叫variance。主要有三種情況:

假設Dog是Animal的子類。那麼有如下關係:

  • 協變(covariant):List[Dog]是List[Animal]的子類,形態用一個+號表示,即List[+A](這裡的A是泛指,類似java中的泛型,可以隨便指定一個字母)。
  • 逆變(contravariant):與協變相反,List[Animal]是List[Dog]的子類,形態用一個-號表示,即List[-A]。
  • 不變(invariant):List[Dog]是List[Animal]的無關,不用任何表示,List[A]。

協變是比較符合正常邏輯思考的,一群狗確實也可以說是一群動物。逆變就比較反直覺了,不過這裡先不討論這點,後面有機會再討論。

3.構建函式式的List

OK,有了上面的基礎,就能夠來構建一個函式式的資料結構了,不過在此之前,先讓我們回顧下傳統的List資料結構。

3.1 傳統的List

還記得以前資料結構是怎樣設計的嗎?

最普通的連結串列,通常都是由一個又一個的Node組成,一個Node中儲存資料和下一個連結串列的變數。最後通過一個空值結尾,通常是Null。

在Java中,它的連結串列Linklist,是通過一個全域性變數size來控制連結串列的。

通過for迴圈實現基礎的增刪查改等操作。而是,也是傳統List的常見寫法,但在函式式的List中可不能這樣。還記得嗎,函式式最大的特點就是無副作用。像java這裡用一個全域性的size來控制,那可真是萬萬不可啊,在多執行緒的情況下還不得崩潰。

關於為什麼要寫無副作用的程式碼,這裡就不做探討,詳細內容可以看看這個系列的第一章。Scala函數語言程式設計指南(一) 函式式思想介紹。

3.2 scala實現函式式的List

我們要做的是寫出無副作用的集合,那要怎麼做呢?給5秒鐘閉上眼睛好好想一想有沒有什麼思路。。。

可能有的同學會想得到,這個答案就是遞迴。通過遞迴,能夠避免副作用的產生。如常用的增刪查改,如果使用遞迴,就可以避免使用一個全域性變數,當然遞迴通常都沒有直接使用for迴圈那麼直觀,所以充滿遞迴的程式碼初次看會比較晦澀。但如果用多了,你會發現其實函式式的程式碼,也是非常好懂的。

下面,我們來看看如果使用遞迴實現一個List。

3.1 定義基本的型別

首先,我們要定義每個節點Node的型別,以及結尾Nil。由於使用到了遞迴,我們需要讓Node和Nil都有同樣的父類,因為遞迴函式的返回都是一樣的。

如果還是不明白為什麼要讓Node和Nil為啥要有同樣的父類,那不妨先放一放,繼續看下去吧。

//定義自己的特質(相當於java的介面),泛型使用協變
sealed trait List[+A]

//定義一個case類,作為每一個List的結尾
case object Nil extends List[Nothing] 

//定義List子類,也可以說是List中的每個Node,每個List都是由一個又一個的Cons組成,以Nil結尾
case class Cons[+A](head: A, tail: List[A]) extends List[A]

注意第一行定義了List[+A]的特質,和scala集合中的List是區分開來的,只是名字叫一樣而已。這個是我們自己的List!!

而後定義了Nil和Cons,分別作為List的結尾和Node節點,注意case class也是scala的語法糖,可以理解為java bean。

之所以先定義了一個List的特質(介面),再分別用Nil和Cons繼承它,是因為在遞迴的情況下,要讓節點和結尾保持同一型別,而這個就是通過多型實現的。

3.2 實現List工廠

前面說到,通常是用object來作為工廠,這裡也是一樣的,我們可以定義List工廠。

定義工廠方法如下:

object List {
  //使用可變長度,如果傳進來的引數是空,就返回Nil,否則使用遞迴返回Cons,注意,這裡的apply方法就是使用了遞迴
  def apply[A](as: A*): List[A] = // Variadic function syntax
    if (as.isEmpty) Nil
    else Cons(as.head, apply(as.tail: _*))

}

這裡的applyA,括號裡面的A*的意思,是多個引數的意思,就是說可以有很多個引數,是scala的一個語法糖。

在最後

else Cons(as.head, apply(as.tail: _*))

看到最後面的 _*了嗎,這個的意思,是除了第一個引數以外的其他引數,也是語法糖。

在這一個小小的地方就用到了遞迴,不斷呼叫apply方法去解析後面的引數,最終生成一個List。初次看可能會比較迷,可能放在編譯器裡面執行一下,方便理解。而這種操作在scala函數語言程式設計中,是非常普遍的做法。

至此,我們就建立了一個List的資料結構,先來看看我們的成果

//一個遞迴的List
scala> List(1,2,3)
res0: List[Int] = Cons(1,Cons(2,Cons(3,Nil)))

現在的List資料結構只是初具雛形,我們還得往裡面加方法。

3.3 用函式式的方式實現List更多方法

通常來說,資料結構比較重要的是增刪查改等操作,但因為是不可變的,同時函式式中通常是不改變物件資訊的,所以這些基本操作反而不是首要的。

我們先來看一個簡單些的例子吧,讓一個List[Int]中的資料累加。

object List {
  ......
  //傳入引數是一個Int型別的List,使用模式匹配
  def sum(ints: List[Int]): Int = ints match {
    case Nil => 0
    //使用遞迴累加
    case Cons(x,xs) => x + sum(xs)
  }
  ......
}

這裡主要傳入的引數是一個Int型別的List,然後使用模式匹配,如果是結尾,則返回0,如果是中間節點,則使用遞迴累加。

上面那個例子比較簡單,明白後可以來看看如何為List構建更加通用的方法。通常比較常用的是前面介紹過的諸如map,filter等操作,下面先用一個map來說明一下吧。

object List {
  ......
  //Map操作,使用模式匹配
  def map[A,B](list: List[A],f: A => B): List[B] =list match {
    case Nil              ⇒ Nil
    //使用遞迴
    case Cons(head, tail) ⇒ Cons(f(head), map(tail,f))
  }
  ......
}

map函式,需要傳進入一個待處理的list,以及一個函式作為引數,用以對List中每個元素做處理。

比如說想讓List中每個元素+1,那就可以傳入

val addOne = (num:Int) => num+1

還記得之前說,在scala中,函式也能當作變數嘛。將addOne這個函式作為引數,這樣就會讓List中每個元素都+1,然後返回一個新的List,當然,這個也是用遞迴實現的。

實現程式碼看起來很簡潔,也是用模式匹配,匹配每個元素的型別,就是是Node還是結尾。如果是結尾,直接返回,如果是Node,那麼處理完當前資料,遞迴去處理後面的資料,並返回新的處理後的Node。

熟悉以後,會發現這樣的處理方式看著很舒服,程式碼寫得也很少,非常簡潔。

在我看來,這就是遞迴的魅力所在。

除了map之外,還有其他操作處理,包括filter,foldLeft,reduce等操作。我把程式碼放在我的公眾號中,限於篇幅這裡就不講太多。關注公眾號:哈爾的資料城堡,回覆“函式式資料結”可以獲得。

程式碼中使用了隱式轉換來擴充List的操作,並演示瞭如何使用隱式轉換,以及如何使用複用來組合功能以實現新的功能。有同學可能不明白為什麼簡單的List要搞這麼複雜,看了程式碼可能會更加理解。

4.函式式的二叉搜尋樹

這部分我是作為練習的,連同List程式碼放在一塊,裡面有基本的結構,但一些缺失的內容需要你來補充。相信我,做了一遍,肯定能夠對函式式的資料結構有更深的理解。

對了,二叉搜尋樹的練習還有幾個test case,做完跑一遍了,如果全過那基本上你寫的程式碼就不會有太大的問題,good luck~

再說一遍我把練習的程式碼放在了我的公眾號中,關注公眾號:哈爾的資料城堡,回覆“函式式資料結構”就能免費獲得啦。

下一篇會再針對List和Tree的程式碼來講一講,有不明白的地方到時候也可以看看。

以上~~


推薦閱讀:
通俗得說線性迴歸演算法(一)線性迴歸初步介紹
通俗得說線性迴歸演算法(二)線性迴歸初步介紹
大資料儲存的進化史 --從 RAID 到 Hadoop Hdfs
C,java,Python,這些名字背後的江湖