内容提要:
代数数据类型 - Algebraic Data Types;
自定义数据类型 - data关键字;值构造器;类型变量与类型构造器;
记录(Record)语法 - 简化自定义数据类型的一种语法糖;
一个完整的例子 - PurchaseOrder定义和简单计算、单元测试;
代数数据类型(Algebraic Data Types)
为什么Haskell的数据类型会有代数数据类型这个名字?回想我们初中时代,初次学习代数的情况,印象最深刻就是x,y,z代替了具体的数字,引入方程式的概念,对
解决问题进行了抽象,比如使用圆的面积计算公式:Area = πr2,其中r就是一个代表圆半径的字母符号。
Haskell就是借鉴代数理论来构建自身的类型体系的。如果构建的类型是由一些确定值组成的,那么就不需要类型变量,这类类型就是一个确定的类型;如果构建的类
型是由一些确定值加上类型变量组成的,那么这种类型就不是具体的类型,而是抽象的类型,在具体使用的时候,等到类型变量替换为具体的类型,才能够成为具体的
类型。空说无凭,马上进入实际的例子。
自定义数据类型
首先看看系统定义的Bool类型:
data Bool = False | True
详细解释一下:
- 使用关键字data进行新类型的定义;
- data后面跟新类型的名字,这个名字必须是大写字母开头;
- 在等号后面,是新类型的可选值表达式,又称为值构造器(value constructors);
- 如果有多个值构造器,之间使用“|”进行分割,表示或者、多种可能的含义;
- 总结来说,Bool类型的定义可以这么理解:Haskell自定义的名字为“Bool”的类型,其取值或者为False,或者为True;
再看看自定义的“Shape”类型:
data Shape = Circle Float Float Float | Rectangle Float Float Float Float
和Bool类型定义略有不同的地方是,Shape有两个值构造器,其中每个值构造器的第一个字符串是其名字,后面是对应的具体类型;
可以这么理解自定义的Shape类型:
- 自定义了名字为“Shape”的类型,其取值可能是一个Circle(圆),或者是一个Rectangle(长方形);
- 如果是Circle,那么由三个Float值组成,分别代表Circle的圆心的横坐标、纵坐标,及其Circle的半径;
- 如果是Rectangle,那么由四个Float值组成,前两个Float代表Rectangle的左上点的横坐标、纵坐标;后两个Float代表Rectangle的右下方的横坐标、纵坐标。
将上面关于Shape自定义类型的代码写入文件Shape.hs文件中,然后使用GHCI加载(编译),然后看看下面的一些交互结果:
-- 加载Shape.hs并编译 :l Shape -- 首先看看True和False的类型是不是Bool :t True -- 结果为:True :: Bool :t False -- 结果为:False :: Bool -- 然后看看Circle和Rectangle的类型是不是Shape:t Circle-- 结果为:Circle :: Float -> Float -> Float -> Shape:t Rectangle-- 结果为:Rectangle :: Float -> Float -> Float -> Float -> Shape -- 可以看到,无论是Cirle还是Rectangle,都是值构造器,返回结果都是Shape
为什么Haskell自定义类型的值构造器是一个大写字符串,表示值构造器的名字呢?比如Bool类型的True,False,和Shape类型的Circle,Rectangle;因为从本质上来说
这个名字其实是一个函数名,通过这个函数名加上具体的参数(可能有,可能没有),就是构造出对应类型的具体值。这种构造具体类型不同值的实现方式,和其他语言有很
大的区别,比如C#,一个Shape类型,不可能有两个不同名字的构造函数。这点需要慢慢体会和适应,至少有一点好处,不同的构造器名字,可读性和表意性会更优。
Haskell自定义类型时,还可以带上类型变量进行抽象,比如Haskell自带的Maybe类型,其定义如下:
data Maybe a = Nothing | Just a
每次看到这个定义,我都由衷地觉得很酷:a是一个类型变量,在定义Maybe类型的时候,加上了这个类型变量,从而构建出一个新的类型,这个类型有两种可能的值:Nothing
表示空,什么都没有;Just a则通过值构建器Just,包装了具体的类型a。至于具体a是什么类型,不是Maybe类型定义时关注的,这极大地丰富了Maybe的内涵,抽象出Maybe的
本质——要么是空,要么就只是a这个东西。
Haskell可以推导中Maybe的一些具体类型,比如:
:t Just 1 -- 结果为:Just 1 :: Num a => Maybe a,表示兼容任何数字类型的类型 :t Just ‘a‘ -- 结果为:Just ‘a‘ :: Maybe Char :t Nothing -- 结果为:Nothing :: Maybe a,由于Nothing没有具体制定a的类型,所以 -- 这个值本身还是多态的
记录语法(Record)
在上面定义Shape的代码中,Circle后面跟了三个Float,Rectangle后面跟了四个Float,初次看到这种定义,肯定会很疑惑,这些Float都是什么含义?没有对应的名字么?
如果是有其他语言背景,特别是面向对象的一些语言,比如C#,Java,我们都熟悉类中属性都是有名字的,这样表意性和可读性才更好。其实一些函数式编程语言,比如Erlang、
Haskell,定义复杂或者组合类型时,都缺乏描述性的支持。好在Record语法,从间接层面可以解决这个问题。
比如如果使用记录语法再次定义Shape类型:
data Shape_Record = Circle { hAxis :: Float, cAxis :: Float, radius :: Float} | Rectangle { leftTopX :: Float, leftTopY :: Float, rightDownX :: Float, rightDownY :: Float} deriving (Show)
在上面使用记录语法定义新类型的例子中,值构造器名字后面,大括号包含的内容,就是记录语法:给定一个小写字母开头的名字,然后是对应的类型说明。有了类型中相关
字段的名字说明,就比较类似C#或者Java中的属性定义了,可读性和易用性得到了提升。
其实从本质上来说,记录语法不过是语法糖,因为类型中每个值对应的名字,其实是一个方法,可以从具体构建的类型实例中,或者对应字段的值。比如:
-- 根据定义的名字,或者对应的值 hAxis Circle { hAxis = 10.0, cAxis = 12.0, radius = 5.5} -- 结果为:10.0
一个实际的例子
假设一家电子商务公司需要向供应商进货,通过生成采购订单和供应商进行采购动作。其中采购订单的主要内容包括:一个订单号、供应商的信息、采购商品的信息等,假设
采购订单本身有一个逻辑检查,即采购订单的总价值等于所有采购商品的价值之和(忽略运费之类的实际情况)。下面的代码展示了采购订单的定义,一些单元测试确保逻辑
正确。
采购订单(PurchaseOrder)定义,及其计算订单总价值(POAmount)的函数定义:
-- PurchaseOrder.hs 文件 module PurchaseOrder where import Data.List -- 导入Data.List模块,需要使用其中定义的函数 -- 首先定义商品,即采购的具体商品,使用记录语法定义 -- Item信息包括:编号、描述、采购数量、单价、总价 data Item = Item { itemNumber :: String , itemDescription :: String, ordQty :: Int, unitPrice :: Float, extPrice :: Float } deriving (Show) -- 给商品的List定义一个别名,便于阅读type ItemList = [Item] -- 定义采购订单,使用记录语法-- 采购订单信息包括:订单编号、供应商编号、收货地址、订单总价、采购商品明细(是一个List)data PurchaseOrder = PurchaseOrder { poNumber :: String, vendorNumber :: String, shipToAddress :: String , poAmount :: Float, itemList :: ItemList } deriving (Show) -- 定义计算采购订单总价的两个函数:逻辑很简单,即采购订单总价,等于其中每个商品的总价之和calculatePOAmount‘ :: PurchaseOrder -> FloatcalculatePOAmount‘ po = foldl (\acc x -> acc + x) 0 [ extPrice i || i <- itemList po] calculatePOAmount :: PurchaseOrder -> PurchaseOrdercalculatePOAmount po = PurchaseOrder { poNumber = (poNumber po) , vendorNumber = (vendorNumber po) , shipToAddress = (shipToAddress po) , poAmount = (calculatePOAmount‘ po) , itemList = (itemList po)}
接下来对上面的代码进行单元测试,主要测试两个逻辑:第一、商品的总价等于单价乘以数量;第二、采购订单的总价等于每个商品的总价之和:
-- Test_PurchaseOrder.hsmodule Test_PurchaseOrder where import PurchaseOrder import Data.List -- build test data buildDefaultTestItem :: Item buildDefaultTestItem = Item {itemNumber = "26-106-016", itemDescription = "this is a test item", ordQty = 100, unitPrice = 10.12, extPrice = 1012}
buildTestItemList :: ItemList
buildTestItemList = [ buildDefaultTestItem | x <- [1..10] ]
-- test methods
checkItemExtPrice :: Item -> Bool
checkItemExtPrice item = (fromIntegral $ ordQty item) * (unitPrice item) == (extPrice item)
checkSingleItem :: Bool
checkSingleItem = checkItemExtPrice $ buildDefaultTestItem
checkItemListExtPrice :: ItemList -> Bool
checkItemListExtPrice itemList = and $ map checkItemExtPrice itemList
checkItemList :: Bool
checkItemList = checkItemListExtPrice $ buildTestItemList
buildPO :: PurchaseOrder
buildPO = PurchaseOrder {poNumber = "1926543", vendorNumber = "28483", shipToAddress = "test address here", itemList = buildTestItemList, poAmount = 0.00}
checkPOAmount :: Bool
checkPOAmount = (fromIntegral $ 1012 * 10) == (poAmount $ calculatePOAmount buildPO)
all_methods_test :: String
all_methods_test = if (and [checkSingleItem, checkItemList, checkPOAmount])
then "All Pass."
else "Failed."
最后将Test_PurchaseOrder.hs装载到GHCI中,通过编译,然后运行其中的all_methods_test方法,结果显示"All Pass",即所有检查的逻辑都是正确的。