轨道
/
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不应带有特定条件,输出的Chest则应添加额外的条件uniqueTreasure : ()。

4. 享受最终的成果

好好享受这些最棒的TreasureChest吧,它们一定会像蜜糖吸引苍蝇一样把冒险者吸引过来。

实现makeTreasureChest,它接收一个既安全又独一无二的Chest,并创建一个TreasureChest。 由于TreasureChest是不透明类型,这将是创建它的唯一方式,再糟糕的地牢管理员也没法把它搞砸。

通过 GitHub 编辑 链接将在新窗口或新标签页中打开
Elm Exercism

准备好开始 宝箱工厂 了吗?

注册 Exercism,借助 28 个概念110 个练习 和真人导师指导,学习并掌握 Elm,全部免费。