學習軌道
/
Elm
Elm
/
練習
/
寶藏工廠
寶藏工廠

寶藏工廠

學習練習

簡介

幻影型別

Elm 的型別支援泛型,這通常能為介面增添彈性。舉例來說,Maybe a 型別可以存放任何型別的值。

type Maybe a = Nothing | Just a

注意在上面這個例子中,型別參數 a 出現在等號 = 的兩側。我們會說 a 綁定到型別定義內部的某個資料。

幻影型別技巧

不過在某些情況下,型別參數只會出現在等式的左側。

type Distance unit = Distance Float

在上面 Distance 的定義中,unit 是一個自由型別參數,並沒有綁定到型別裡的任何資料。我們也把這種型別稱為幻影型別。

當我們想在編譯期強制執行某些限制時,它出乎意料地好用。舉例來說,我們想確保只把相同單位的距離相加。

-- Distance is an opaque type, since the module does not expose its variants.
-- This means that users may only use the functions meter, foot and add to manipulate distances.
module Distance exposing (Distance, Meter, Foot, meter, foot, add)

-- The Distance type has a phantom type 'unit'.
type Distance unit = Distance Float

-- We define two types that will be used in place
-- of the phantom type 'unit' in our constructor functions.
type Meter = Meter
type Foot = Foot

-- Constructor for Meter
meter : Distance Meter
meter = Distance 1.0

-- Constructor for Foot
foot : Distance Foot
foot = Distance 0.3048

-- The add function cannot take two parameters of different types.
-- So we cannot add meters and feet by mistake.
add : Distance unit -> Distance unit -> Distance unit
add (Distance d1) (Distance d2) = Distance (d1 + d2)

這個模組以外的程式碼無法存取 Distance 型別的內部。使用者可以把兩個 Distance Meter 相加,卻不能把 Distance Meter 和 Distance Foot 相加。

import Distance exposing (Distance)

-- Compiles
twoMeters = Distance.add Distance.meter Distance.meter

-- Does not compile
errDist = Distance.add Distance.meter Distance.foot

用可擴充記錄當作幻影型別

任何型別都可以當作幻影型別,沒有限制,記錄型別也一樣可以。記錄當作幻影型別使用時,能夠表達會透過函式轉換的複雜限制。

我們在 Distance 模組裡加入一個新的距離單位,叫做 LegoBlock。實體積木是受到規範的物件,永遠具備兩個屬性:它們的距離必須是非分數且非負。

type LegoBlock = LegoBlock

fourStuds : Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
fourStuds = Distance 4.0

nonFractional 和 nonNegative 是某個記錄的欄位,但這個記錄只存在於型別引數中,所以很適合把它們的型別設為 (),也就是 單元型別;它除了表示自己在這裡之外,不帶任何資訊。

當然,要取得任意的 LegoBlock 距離也是可以的,例如在計算距離的差值或比值之後。

negativeStud : Distance { properties | unit: LegoBlock, nonFractional : () }
negativeStud = Distance -1.0

threeFiddyStud : Distance { properties | unit: LegoBlock, nonNegative : () }
threeFiddyStud = Distance 3.50

crazyStud : Distance { properties | unit: LegoBlock }
crazyStud = Distance -13.37

上面這些值都合法,也都能通過編譯;不過我們特別在意像 fourStuds 這樣同時具有 nonFractional 和 nonNegative 兩個屬性的值,因為它們代表的是可以互相結合的實體積木:

combineLegoBlocks
  :  Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
  -> Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
  -> Distance { properties | unit: LegoBlock, nonFractional : (), nonNegative: () }
combineLegoBlocks = add

我們再加入一些函式,讓使用者可以建立和調整 LegoBlock 距離吧。

newLegoBlock : Float -> Distance { properties | unit: LegoBlock }
newLegoBlock dist = Distance dist

floorDistance : Distance properties -> Distance { properties | nonFractional : () }
floorDistance (Distance dist) = Distance (toFloat (floor dist))

ceilingDistance : Distance properties -> Distance { properties | nonFractional : () }
ceilingDistance (Distance dist) = Distance (toFloat (ceiling dist))

absDistance : Distance properties -> Distance { properties | nonNegative : () }
absDistance (Distance dist) = Distance (abs dist)

注意 floorDistance、ceilingDistance 和 absDistance 也能處理 LegoBlock 以外的單位,而且一般來說不會對輸入的屬性做任何假設,它們只保證輸出的值會具有某個特定屬性,也就是 nonFractional 或 nonNegative。

我們來看看一些結果。

import Distance exposing (Distance)

-- Compiles
distance1 = combineLegoBlocks fourStuds fourStuds

-- Does not compile
distance2 = combineLegoBlocks fourStuds threeFiddyStud

-- Compiles
distance3 = combineLegoBlocks fourStuds (floorDistance threeFiddyStud)

-- Does not compile
distance4 = combineLegoBlocks fourStuds negativeStud

-- Compiles
distance5 = combineLegoBlocks fourStuds (absDistance negativeStud)

-- Does not compile
distance6 = combineLegoBlocks fourStuds crazyStud

-- Compiles
distance7 = combineLegoBlocks fourStuds (floorDistance (absDistance crazyStud))

-- Compiles
distance8 = combineLegoBlocks fourStuds (ceilingDistance (absDistance crazyStud))

一般來說,floorDistance 和 ceilingDistance 會產生不同的結果,但提供相同的保證。這正是幻影型別技巧的威力:在維持強大保證的同時,給使用者有彈性的選擇。

說明

你,邪惡大師,對遍布世界各地邪惡地城中每個 TreasureChest 的品質深感自豪。 要維持這份品質可不容易,你的地城管理員老是搞砸寶藏製作,於是你決定提供一個 Elm API,讓他們乖乖聽你的話。

有兩個條件是你絕不退讓的:

  1. TreasureChest 必須由至少 8 個字元的安全密碼保護
  2. 在特定地城中,每個 TreasureChest 都應該裝著獨一無二的寶藏

你的地城管理員會提出一份密碼/寶藏建議清單,只有合適的 TreasureChest 才會被建立出來。

根據這些條件,要從一份建議清單建立安全的寶箱有兩種可能的方式:

  1. 先移除密碼不安全的,再移除寶藏重複的
  2. 先移除寶藏重複的,再移除密碼不安全的

這兩種做法可能不會得到相同的結果(例如 [("strong_password", GoldStatue), ("1234", GoldStatue)]),但你兩種都不介意,所以你想讓地城管理員自己決定。

一個能給使用者一些選擇,又能保證最終結果具備某些性質的 API? 聽起來正好適合幻影型別技巧!

1. 提供寶箱的佔位符

TreasureChest 型別和它搭配的 getTreasure 已經提供好了,但你需要為寶箱建議構思一個型別。

實作 Chest 型別、實作 makeChest,並修正 secureChest 和 uniqueTreasures 的型別簽章。 makeChest 和 makeTreasureChest 的型別簽章已經在需求中提供,請不要修改它們。

Chest 應該裝著和 TreasureChest 相同的資料,並且有兩個型別引數,一個是 treasure,另一個是給 conditions 用的記錄幻影型別。 注意,因為 Chest 使用了幻影型別,所以應該是不透明的,以防止它在 TreasureFactory 模組之外被使用。 在這個情況下,它甚至完全沒有被公開。

修改 secureChest 和 uniqueTreasures 的型別簽章,用可擴充記錄作為幻影型別來加上限制。 secureChest 應該接受一個沒有特定條件的 Chest,並回傳 Maybe Chest,其幻影記錄中帶有額外的條件 securePassword : ()。 uniqueTreasures 應該接受一個沒有特定條件的 List Chest,並回傳 List Chest,其幻影記錄中加上額外的條件 uniqueTreasure : ()。

Note

Elm 測試可以存取已公開的函式,但無法存取型別簽章,因此測試不可能驗證你用的簽章是否正確。 當然,要確認簽章是否正確,最有力的證明就是讓模組和測試能順利編譯並執行,不過我們寫了一條分析器規則,在你提交解法後會檢查你的型別簽章。

2. 挑選安全的寶箱

寶箱準備好之後,你需要挑出安全的那些。

實作 secureChest,讓它只對密碼長度為 8 個字元以上的寶箱回傳 Just。 符合該條件的 Chest 應該在其幻影型別中加上額外的 securePassword : () 條件。

3. 挑選獨一無二的寶藏

地城裡只該允許最稀有的寶藏,就算只多一份也會讓寶藏顯得廉價。

實作 uniqueTreasures,它接受一個 Chest 的陣列,並回傳輸入陣列中擁有獨一無二寶藏的 Chest 陣列。 如果某個寶藏在輸入中出現兩次,它就不應該出現在輸出中。 輸入的 Chest 不應該有特定條件,輸出的則應該加上額外的條件 uniqueTreasure : ()。

4. 享受最終成果

好好享受最棒的 TreasureChest 吧,它們肯定會像蜜糖吸引蒼蠅一樣引來冒險者喔。

實作 makeTreasureChest,它接受一個既安全又獨一無二的 Chest,並建立一個 TreasureChest。 由於 TreasureChest 是不透明型別,這會是建立它的唯一方式,就連糟糕的地城管理員也無法搞砸。

透過 GitHub 編輯 連結會在新視窗或分頁中開啟
Elm Exercism

準備好開始 寶藏工廠 了嗎?

註冊 Exercism,透過 28 個概念110 個練習 和真人引導來學習並精通 Elm,全部免費。