Comprehension 表示

February 2, 2022

數學會使用集合建構式符號來描述集合(Set),像是有 10 個元素的 5 倍數集合可以表示為 {5 * x | x ∈ N, x ≤ 10},這也稱為 Set Comprehension。

如果有個需求是產生一個 list,其中的元素就是 {5 * x | x ∈ N, x ≤ 10},根據〈惰性求值〉的說明,可以使用 take 10 $ [5, 10 ..],這就會建立 [5,10,15,20,25,30,35,40,45,50],不過,若寫成 [5 * x | x <- [1 .. 10]],也能得到相同的結果,寫法與 Set Comprehension 類似,一看就懂,因為產生的結果是個 list,因而稱為 List Comprehension。

就如從 Set Comprehension 可一眼就看出集合定義,某些 Haskell 程式碼可以改寫為 List Comprehension,讓程式的意圖更明顯,就像方才將 take 10 $ [5, 10 ..] 改為 [5 * x | x <- [1 .. 10]] 那樣。

map 與 filter

List Comprehension 能做到 map 函式的功能,例如 map (+ 3) [1, 2, 3, 4, 5],也可以使用 [x + 3 | x <- [1, 2, 3, 4, 5]],也能使用 List Comprehension 來實作 map' 函式:

map' :: (a -> a) -> [a] -> [a]
map' mapper lt = [mapper elem | elem <- lt]

如果 List Comprehension 加上條件式,可以達到單純過濾元素的功能,例如 filter (> 3) [1, 2, 3, 4, 5],也可以使用 [x | x <- [1, 2, 3, 4, 5], x > 3] 來得到,也能使用 List Comprehension 來實作 filter' 函式:

filter' :: (a -> Bool) -> [a] -> [a]
filter' predicate lt = [elem | elem <- lt, predicate elem]

就簡單的對應與過濾來說,使用 [x + 3 | x <- [1, 2, 3, 4, 5]][x | x <- [1, 2, 3, 4, 5], x > 3],不見得比 map (+ 3) [1, 2, 3, 4, 5]filter (> 3) [1, 2, 3, 4, 5] 來得清楚,不過,如果是 map (+ 3) $ filter (> 3) [1, 2, 3, 4, 5][x + 3 | x <- [1, 2, 3, 4, 5], x > 3] 相比,那後者倒是就清楚許多。

更多 List Comprehension

來看看更多 List Comprehension 的語法,如果在取得 List 中的元素時,實際上不需要元素值,那麼 <- 左邊可以使用 _,例如,[1 | x <- [1, 2, 3, 4, 5]],可以寫成 [1 | _ <- [1, 2, 3, 4, 5]],嗯?這是什麼?例如,來用 List Comprehension 寫個 leng 函式:

leng :: [a] -> Int
leng lt = sum [1 | _ <- lt]

每取得一個元素就計數 1,最後加總就是 list 的長度,如果要取得三邊長不大於 10 的直角三角形呢?三角不等式定義是兩邊之和大於第三邊,而直角三角型是兩邊的平方和等於斜邊平方。如果你要用 filtermap 兜出這個需求可不容易,如果用 List Comprehension,三邊長不大於 10,可以使用三個 [1..10] 列舉,若以其中一個 [1..10] 作為最長邊,依三角不等式定義,使用 List Comprehension 可以寫成:

[[a, b, c] | 
    a <- [1..10], b <- [1..10], c <- [1..10],
    a <= b, b <= c]

List Comprehension 可以列舉多個 list,只要以 , 做區隔,接下來加上直角三角型定義:

[[a, b, c] | 
    a <- [1..10], b <- [1..10], c <- [1..10],
    a <= b, b <= c, 
    a ^2 + b ^ 2 == c ^ 2]

最後得到 [[3,4,5],[6,8,10]] 的結果,只要寫下問題定義,Haskell 就會為你求解。

命令式?宣告式?

只要寫下問題定義,Haskell 就會為你求解,這樣的風格就是宣告式(Declarative),這是函數式程式設計與命令式(Imperative)程式設計不同之處,在宣告式的設計中,你著重的是 WHAT,而不是 HOW,就像上面求直角三角形的例子,你著重的是「直角三角形是什麼?」,而不是「如何找出直角三角形」,如果是命令式求解,例如 Java 的話,會像是這樣:

var tris = new ArrayList<List<Integer>>();
range(1, 11).forEach(a -> {
    range(1, 11).forEach(b -> {
        range(1, 11).forEach(c -> {
            if(a <= b && b <= c && a * a + b * b == c * c) {
                tris.add(asList(a, b, c));
            }
        });
    });
});

程式碼變成著重在 List 要如何建立,儘管已經使用了 Lambda 相關功能,程式碼仍不見得好懂。

來舉另一個例子,〈阿姆斯壯數〉,其定義為在 n 位的整數中,加總每個數字的 n 次方後等於該整數,例如 153 是個阿姆斯壯數,因為 13 + 53 + 33 = 153

如果你的需求是,找出 3 位數的阿姆斯壯數有哪些,那你要定義(宣告)「什麼」是 3 位數的阿姆斯壯數,也就是「在 3 位的整數中,加總每個數字的 3 次方後等於該整數」。

在〈阿姆斯壯數〉,已經示範了一種定義方式,其中需要將 n 位整數分解為各數字,再判斷加總每個數字的 n 次方後等於該整數,這邊可以換另一個方向,首先定義 3 位的整數:

[x * 100 + y * 10 + z| 
    x <- [1..9], y <- [0..9], z <- [0..9]]

接下來,加入加總每個數字的 3 次方後等於該整數的定義:

[x * 100 + y * 10 + z| 
    x <- [1..9], y <- [0..9], z <- [0..9], 
    x ^ 3 + y ^ 3 + z ^ 3 == x * 100 + y * 10 + z]

List Comprehension 中可以使用 let,例如,可以令 x * 100 + y * 10 + znumber,這樣就不用重複計算這個算式了:

[number| 
    x <- [1..9], y <- [0..9], z <- [0..9], 
    let number = x * 100 + y * 10 + z, 
    x ^ 3 + y ^ 3 + z ^ 3 == number]

跟命令式比較一下,例如 Java 的實作,哪個比較清楚呢?

var numbers = new ArrayList<Integer>();
range(1, 10).forEach(x -> {
    range(0, 10).forEach(y -> {
        range(0, 10).forEach(z -> {
            var number = x * 100 + y * 10 + z;
            if(x * x * x + y * y * y + z * z * z == number) {
                numbers.add(number);
            }
        });
    });
});

分享到 LinkedIn 分享到 Facebook 分享到 Twitter