`
winzenghua
  • 浏览: 1325146 次
  • 性别: Icon_minigender_2
  • 来自: 广州
文章分类
社区版块
存档分类
最新评论

[Real world Haskell] 中文翻译:第三章 定义类型,流式函数

阅读更多

第三章: 定义类型,流式函数

定义新的数据类型

虽然列表和元组很有用,我们还是经常想要构建自己的数据类型。这可以我们在的程序的值上增加结构。可以把相关的值集合起来给一个名字,与其他类型相区分,来替代匿名元组的使用。定义自己的类型也可以提升代码的类型安全性:Haskell不会让我们偶尔把两个结构相似但类型名不同值弄混。

为增加点动力,我们来考虑一个小的在线书店需要管理的数据类型。我们不会尝试做完整的和实际的数据定义,不过至少和现实世界问题联系起来了。

用 data 关键字定义新的数据类型。

-- file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
deriving (Show)

data关键字后面的BookInfo是新数据类型的名字。称BookInfo为类型构造子。定义了类型后,就可以用它的类型构造子来引用它。已经提到果,类型名即类型构造子必须以大写字母开头。

后面的Book是值构造子(有时称为数据构造子)。用它创建一个BookInfo类型的值。值构造子的名字也必须以大写字母开头。

Book后面跟的Int,String和[String]是这个类型的分量。Haskell中的分量与其他语言中结构或类的成员用途一样:保存数据的“槽”。(我们将经常用成员来指分量)

在这个例子中,Int表示书的id(例如,存在数据库中的id值),String是它的标题,[String]是它的作者的名字。

与刚看到的概念相联系,BookInfo类型与一个(Int, String, [String]) 类型的三元组含有相同的分量,但是它们是不同的类型。我们不会偶尔(或者故意)的在需要一种类型的环境下错用成另一种。例如,书店也可能会卖杂志。

-- file: ch03/BookStore.hs
data MagazineInfo = Magazine Int String [String]
deriving (Show)

尽管MagazineInfo类型与BookInfo类型有相同的结构,Haskell还是会把它们当作不同的类型,因为它们的类型和值的构造子有不同的名字。

[Note] Deriving ?
后面在"Show"一节会全面解释 deriving (Show)的含义。现在,只要知道我们把这个加到类型的定义中,可以使得ghci自动获得打印这个类型的值的功能就够了。

通过把Book当作一个函数,并给他应用 Int, String和 [String]类型的参数,就可以创建一个新的BookInfo类型的值。

-- file: ch03/BookStore.hs
myInfo = Book 9780135072455 "Algebra of Programming"
["Richard Bird", "Oege de Moor"]

定义了类型后,就可以在ghci中实验它。先用:load命令载入源文件。

ghci> :load BookStore
[1 of 1] Compiling Main ( BookStore.hs, interpreted )
Ok, modules loaded: Main.

还记得在源文件中定义的 myinfo 变量么?这里可以看到。

ghci> myInfo
Book 9780135072455 "Algebra of Programming" ["Richard Bird","Oege de Moor"]
ghci> :type myInfo
myInfo :: BookInfo

也可以在ghci中交互的创建新的值。

ghci> Book 0 "The Book of Imaginary Beings" ["Jorge Luis Borges"]
Book 0 "The Book of Imaginary Beings" ["Jorge Luis Borges"]

ghci的:type命令可以看到一个表达式的类型。

ghci> :type Book 1 "Cosmicomics" ["Italo Calvino"]
Book 1 "Cosmicomics" ["Italo Calvino"] :: BookInfo

记住在ghci中定义新的变量与在Haskell源文件中稍微有些不同:需要在前面加上let。

ghci> let cities = Book 173 "Use of Weapons" ["Iain M. Banks"]

要看到一个类型更多的信息,可以用ghci的一些浏览能力。:info命令让ghci把它知道的一个名字的所有信息告诉我们。

ghci> :info BookInfo
data BookInfo = Book Int String [String]
-- Defined at BookStore.hs:4:5-12
instance Show BookInfo -- Defined at BookStore.hs:4:5-12

也可以看出为什么用Book来创建新的BookInfo类型的值。 (译注:原文错写成 BookStore)

ghci> :type Book
Book :: Int -> String -> [String] -> BookInfo

可以将值构造子当成另一个函数,它能恰好创建并返回我们需要的类型的一个新值。

类型与值的命名

(译注:原文的BookStore用错?)

介绍BookInfo类型时,我们故意选择了不同的名字来区分类型构造子BookInfo和值构造子Book,纯粹是为了区分起来更明显。

但是在Haskell中,类型与值的名字是相互独立的。只在类型声明或类型签名中使用类型构造子(即类型的名字)。只在实际的代码中使用值构造子。因为它们是区别开的,因此类型构造子与值构造子使用相同的名字不会造成混淆。如果在写类型构造子,必须用类型构造子。如果在写表达式,必须用值构造子。


-- file: ch03/BookStore.hs
-- We will introduce the CustomerID type shortly.

data BookReview = BookReview BookInfo CustomerID String

这个定义说名为BookReview的类型的值构造子也名为BookReview。

值构造子的名字与类型构造子名字相同不单是合法的,而且是常见的:在一般的Haskell代码中总是可以看到。


类型别名

任何时候都可以个已存在的类型一个别名,可给一个类型更具描述性的名字。例如,BookReview类型中的String没有告诉我们这个字符串是干什么的,不过我们可以明确它。

-- file: ch03/BookStore.hs
type CustomerID = Int
type ReviewBody = String

data BetterReview = BetterReview BookInfo CustomerID ReviewBody

type关键字给类型引入一个别名。新的名字在=左边,已有的名字在右边。两个名字都标识同样的类型,因此类型别名纯粹是为了代码的可读性。

也可以用类型别名来给冗长的类型名创建一个简短的名称。

-- file: ch03/BookStore.hs
type BookRecord = (BookInfo, BookReview)


这说明我们可以用 BookRecord 当作元组 (BookInfo, BooReview) 的别名。类型别名知识给已经存在的类型名一个新的名字。我们仍然使用同样的值构造子来创建这个类型的值。

代数类型

熟悉的Bool类型是称为代数类型的一种类型里最简单的例子。代数类型可以有多于一个值构造子。

-- file: ch03/Bool.hs
data Bool = False | True

Bool类型有两个值构造子:True和False。在定义中每一个值构造子用 | 字符分隔,可以读作“或”:可以创建一个值为True或者值为False的Bool类型值。一个类型有多于一个值构造子时,它们通常可以被互换或分支。可以替代为任意一个值构造子来创建这种类型的值。

[Note] 关于命名

虽然“代数类型”(algebraic data type)比长,不过我们要小心避免使用缩写“ADT”。那个缩写已经被广泛理解为指代“abstract data type”(抽象数据类型)。因为Haskell对代数和抽象数据类型都支持,因此我们会明确使用并完全避免此缩写。

每个代数类型的值构造子可以有零个或多个参数。例如,这里是一种表示账单信息的方法。

-- file: ch03/BookStore.hs
type CardHolder = String
type CardNumber = String
type Address = [String]

data BillingInfo = CreditCard CardNumber CardHolder Address
| CashOnDelivery
| Invoice CustomerID
deriving (Show)

这里支持三种给客户的账单方式。如果要用信用卡腹胀,需要把卡号,持有者姓名,持有者的账单地址当作参数提供给CreditCard 这个值构造子。或者,用户可以给送货人直接付现金。因为这种方式不需要保存额外的信息,因此CashOnDelivery构造子没有参数。最后,可以给特定用户送一张发票,需要把CustomerID当作Invoice构造子的参数。

当用值构造子来创建BillingInfo类型的值时,必须提供相应的参数。

ghci> :type CreditCard
CreditCard :: CardNumber -> CardHolder -> Address -> BillingInfo
ghci> CreditCard "2901650221064486" "Thomas Gradgrind" ["Dickens", "England"]
CreditCard "2901650221064486" "Thomas Gradgrind" ["Dickens","England"]
ghci> :type it
it :: BillingInfo
ghci> Invoice

<interactive>:1:0:
No instance for (Show (CustomerID -> BillingInfo))
arising from a use of `print' at <interactive>:1:0-6
Possible fix:
add an instance declaration for (Show (CustomerID -> BillingInfo))
In the expression: print it
In a 'do' expression: print it
ghci> :type it
it :: BillingInfo

产生 No instance 的错误信息是因为没有给Invoice构建子提供参数。因此,我们是在试图打印Invoice构造子本身。这个构造子需要一个参数并返回一个值,所以它是一个函数。在Haskell中不能打印函数,因此解释器产生错误提示。

元组与代数类型,及使用的选择

在元组与代数类型之间存在一些重叠。如果愿意的花,可以把之前的BookInfo类型用(Int, String, [String])元组来表示。

ghci> Book 2 "The Wealth of Networks" ["Yochai Benkler"]
Book 2 "The Wealth of Networks" ["Yochai Benkler"]
ghci> (2, "The Wealth of Networks", ["Yochai Benkler"])
(2,"The Wealth of Networks",["Yochai Benkler"])

代数类型可以让我们与其他同构的信息区分开。两个元素类型相同并且结构也相同的元组是同构的,因此是同一种类型。 (?同构, identical)

-- file: ch03/Distinction.hs
a = ("Porpoise", "Grey")
b = ("Table", "Oak")

因为它们有不同的名字,两个代数类型即使具有等价的结构,也会被区分成不同的类型。

-- file: ch03/Distinction.hs
data Cetacean = Cetacean String String
data Furniture = Furniture String String

c = Cetacean "Porpoise" "Grey"
d = Furniture "Table" "Oak"

这可以让带有类型系统的程序产生更少的bug。用上面定义的元组的话,可以想象到给一个需要椅子作为参数的函数传递一个描述鲸鱼的信息,而类型系统无法在此情况下帮我我们。用代数类型的话就不会产生这种混淆。

这里是更明显的例子。考虑下面二维向量的表示方法。

-- file: ch03/AlgebraicVector.hs
-- x and y coordinates or lengths.
data Cartesian2D = Cartesian2D Double Double
deriving (Eq, Show)

-- Angle and distance (magnitude).
data Polar2D = Polar2D Double Double
deriving (Eq, Show)


笛卡尔坐标和极坐标形式都是使用两个同样类型的元素。然而,这些元素的含义是不同的。因为 Cartesian2D和Polar2D是不同的类型,类型系统可以保证我们不会不小心在需要Polar2D时错用Cartesian2D值,反之亦然。


ghci> Cartesian2D (sqrt 2) (sqrt 2) == Polar2D (pi / 4) 2

<interactive>:1:33:
Couldn't match expected type `Cartesian2D'
against inferred type `Polar2D'
In the second argument of `(==)', namely `Polar2D (pi / 4) 2'
In the expression:
Cartesian2D (sqrt 2) (sqrt 2) == Polar2D (pi / 4) 2
In the definition of `it':
it = Cartesian2D (sqrt 2) (sqrt 2) == Polar2D (pi / 4) 2


(==)操作符需要它的参数拥有相同的类型。


[Tip]相等性比较
注意在向量类型定义的deriving子句中,加了另一个词 Eq。这可以让Haskell的实现生成比较值是否相等的代码。

如果用元组来表示这些值,很快就会被不同的表示方法搞混。

ghci> (1, 2) == (1, 2)
True

在这里类型系统救不了我们:我们比较的是两个(Double, Double)对,而这完全是合法的。实际上我们无法区分出值应该是笛卡尔坐标还是极坐标,但是(1,2)在每种表示里有完全不同的含义。

没有强制和简洁的教条去确定是否应该用元组还是应该用相区分data类型,不过有一个主要的规则可以遵从。如果在代码中广范使用组合值(几乎所有的实际程序都如此),增加data声明会提高类型安全性和代码可读性。对于小的局部的使用,tuple一般就可以了。

其他语言中类似代数类型的语法

代数类型提供了强大的描述数据类型的单独方法。其他语言经常需要多种不同的特性组合在一起才能获得这种程度的表达能力。这里是一些C和C++中的相似的语法结构,可以清楚的看到代数类型可以做什么,并与更熟悉的概念联系起来。

结构体

只有一个构建子的代数类型类似于元组:把相关的值组织在一起形成一个组合值。与C或C++中的结构体对应,它的分量对应于结构体中的成员。这里是前面定义的BookInfo类型的C里的等价物。

struct book_info {
int id;
char *name;
char **authors;
};

两者的主要不同电视Haskell类型中的成员是匿名的,并且位置固定。

-- file: ch03/BookStore.hs
data BookInfo = Book Int String [String]
deriving (Show)

位置固定意思是说Haskell的类型中数字是第一个成员,标题在第二个。用位置引用它们而不是用名字。

在“模式匹配”一节,可以看到如何访问BookInfo值中的成员。在“记录语法”一节,将引入一个替代的语法可以定义看上去有点像C的数据类型。

枚举

在C或C++中使用枚举的地方,也可以代数类型来表示一组符号值。这种代数类型有时称为枚举类型。这里是C中的一个例子。

enum roygbiv {
red,
orange,
yellow,
green,
blue,
indigo,
violet,
};

这是Haskell中的等价物。

-- file: ch03/Roygbiv.hs

data Roygbiv = Red
| Orange
| Yellow
| Green
| Blue
| Indigo
| Violet
deriving (Eq, Show)


可以在ghci中试下。

ghci> :type Yellow
Yellow :: Roygbiv
ghci> :type Red
Red :: Roygbiv
ghci> Red == Yellow
False
ghci> Green == Green
True

在C里面,枚举的元素是整数值。在需要枚举值的地方可以用整数替代,反之亦然:C编译器会在两种类型之间自动转换。这可能产生讨厌的bug。在Haskell中,这类问题不会出现。例如,不能在需要Int的地方使用Roygbiv值。

ghci> take 3 "foobar"
"foo"
ghci> take Red "foobar"

<interactive>:1:5:
Couldn't match expected type `Int' against inferred type `Roygbiv'
In the first argument of `take', namely `Red'
In the expression: take Red "foobar"
In the definition of `it': it = take Red "foobar"

可区分的联合

如果一个代数类型有多种可选项,可以认为与C 或 C++中的联合(union)类似。这两者间一个主要的区别是联合并不会告诉我们实际用的是哪种选项;我们必须显示的手动跟踪我们正在用的那种选项,经常用所包含的结构体的另一个成员。如果搞错了所存的值是哪一种的话,联合就会造成bug的产生。

enum shape_type {
shape_circle,
shape_poly,
};

struct circle {
struct vector centre;
float radius;
};

struct poly {
size_t num_vertices;
struct vector *vertices;
};

struct shape
{
enum shape_type type;
union {
struct circle circle;
struct poly poly;
} shape;
};

在上面的例子里,在联合里可以合法的存储一个圆或者一个多边形。必须用枚举值shape_type来手动的指出当前联合中存储的时那一种值。

Haskell版本的代码比C中的等价程序要显著的短并且更安全。

-- file: ch03/ShapeUnion.hs
type Vector = (Double, Double)

data Shape = Circle Vector Double
| Poly [Vector]


用Circle构建子创建一个Shape值,就保存了一个Circle值,后面用这个Circle时,不会不小心把它当成Square。在“模式匹配”一节中将看到原因。

[Tip] 一些说明
读过前面的几节,可以清楚知道所有的用data关键字定义的数据类型都是代数类型。有些只有一个可选项,其他的可以有多个,不过它们都使用同样的机制。

模式匹配


现在已经看过如何构造代数类型的值,现在开始讨论如何处理这些值。如果有某种类型的值,我们要能对它做两件事。

* 如果这个类型有多个值构造子,我们需要能指出这个值是用哪个值构造子创建的。

* 如果值构造子有数据分量,需要能把这些值提取出来。


Haskell有个简单但却极其有用的模式匹配功能,可以完成这两件事。

模式可以让我们看到值的内部并把它包含的数据绑定到变量上。下面的例子是模式匹配在Bool值上的实际使用:重新实现not函数。

-- file: ch03/add.hs
myNot True = False
myNot False = True

看山去这里好像有两个叫myNot的函数,但Haskell可以让我们用一系列等式来定义函数:这两个子句定义了同一个函数在不同的输入模式时的行为。在每一行上,跟在函数名后直到 = 号前的项就是模式。

要理解模式匹配的工作方式,一步步的看下这个例子,如执行 myNot False。

当应用 myNot时,Haskell的运行时将我们提供值与第一个模式的值构造子相比较。第一个不匹配,因此它转向检查第二个模式。匹配成功,因此它用这个等式的右侧表达式作为整个函数应用的结果。

这是有些扩展的例子。这个函数把一个列表中的元素加在一起。

-- file: ch03/add.hs
sumList (x:xs) = x + sumList xs
sumList [] = 0

让我们一步步的看下 sumList [1, 2] 的求值。列表记号[1, 2] 是表达式 (1: (2: [])) 的简写。开始尝试匹配sumList定义中的第一个等式的模式。在 (x:xs) 模式中,":" 类似于列表构造子 (:)。这里我们用它来与一个值做匹配,而不是用来构造值。(1:(2:[]))是用 (:) 构造的,因此值中的构造子与模式中的构造子匹配。我们说这个模式匹配,或者匹配成功。

现在变量x和xs“绑定”到构造子的参数上,x赋值为1,xs赋值为 2: []。

现在要求值的表达式是 1 + sumList (2:[])。现在必须在值2:[]上递归应用sumList。2:[]又是用(:)构造的,匹配成功。在sumList的递归应用中,x现在绑定到2, xs绑定到[]。

现在要对 1 + (2 + sumList [])求值。在sumList的这次递归应用中,要匹配的值是[]。这个值的构造子与第一个模式不匹配,因此跳过此等式。我们“掉到”下一个模式,它匹配了。这样等式右边的表达式就选为这次函数应用的结果。

这样 sumList [1, 2] 的结果就是 1 + (2 + (0)),或者3.

[Note] 顺序是重要的
已经提到过,Haskell的实现在检查模式的匹配时按照我们所给出等式的顺序。匹配从上到下,直到第一次匹配成功。在匹配成功的子句下面的等式不起作用。

最后说明下,已经有一个标准函数sum可以执行列表求和。我们的sumList只是为了演示。

构造和解构

退回来看下构造一个值与对他模式匹配之间的关系。

应用值构造子创建一个值。表达式 Book 9 "Close Calls" ["John Long"] 把值 9, "Close Calls", ["John Long"]应用到Book构建子上,生成一个新的BookInfo类型的值。

对Book构建子进行模式匹配时,把创建的过程反过来。首先,检查下值是否时用这个构造子创建的。如果是的话,就把最初创建这个值时它提供给它的构造子的各个单独值取出来。

考虑下如果用 (Book id name authors)模式来与我们的例子表达式进行模式匹配会发生什么。

* 匹配会成功,因为值中的构造子与我们的模式中的那个相同。

* 变量id会变绑定到9。

* 变量name会绑定到 "Close Calls"。

* 变量authors会绑定到 ["John Long"]。

因为模式匹配与构建过程相反,因此有时被称为解构。

[Note] 解构不破坏任何东西

如果你习惯了面向对象的黑话,那么不要把解构和析构搞混。对要检查值进行模式匹配不会给它造成任何影响:只是让我们能看到他的内部。

进一步探险
对元组的模式匹配的语法与创建元组的语法相似。这里时返回一个三元组最后一个元素的函数。

-- file: ch03/Tuple.hs
third (a, b, c) = c

对值的模式匹配的“深度”没有限制。这个定义不单深入元组的内部,而且深入到元组中一个列表的内部。


-- file: ch03/Tuple.hs
complicated (True, a, x:xs, 5) = (a, xs)

可以交互的尝试下。

ghci> :load Tuple.hs
[1 of 1] Compiling Main ( Tuple.hs, interpreted )
Ok, modules loaded: Main.
ghci> complicated (True, 1, [1,2,3], 5)
(1,[2,3])

一旦字面量出现在一个模式中(上面的元组的模式中 True 和 5),这些值必须被精确的匹配才能使模式匹配成功。如果一组等式的每一个模式都没有匹配成功,将导致运行时错误。

ghci> complicated (False, 1, [1,2,3], 5)
*** Exception: Tuple.hs:10:0-39: Non-exhaustive patterns in function complicated

要解释这个错误信息,可以先向后跳过一些,到“模式完整性与通配符”一节。

可以用值构造子来对代数类型的值进行模式匹配。回忆下之前定义的BookInfo类型:可以像下面这样把各个值从一个BookInfo值中提取出来。

-- file: ch03/BookStore.hs
bookID (Book id title authors) = id
bookTitle (Book id title authors) = title
bookAuthors (Book id title authors) = authors

实际看下它的用法。

ghci> bookID (Book 3 "Probability Theory" ["E.T.H. Jaynes"])
3
ghci> bookTitle (Book 3 "Probability Theory" ["E.T.H. Jaynes"])
"Probability Theory"
ghci> bookAuthors (Book 3 "Probability Theory" ["E.T.H. Jaynes"])
["E.T.H. Jaynes"]


编译器可以基于我们模式中使用的构造子来推导出读取函数的类型。

ghci> :type bookID
bookID :: BookInfo -> Int
ghci> :type bookTitle
bookTitle :: BookInfo -> String
ghci> :type bookAuthors
bookAuthors :: BookInfo -> [String]

如果在模式中使用一个字面值,要去匹配的值的相应的部分必须包含一个一样的值。例如:模式(3:xs),首先为匹配(:)构造子,将先检查下所要匹配的值是否一个非空的列表。它也会确保此列表的第一个元素恰好是值3。如果这两个条件都达到了,这个列表的尾部将绑定到变量xs上。

模式中的变量命名

在读一些匹配列表的函数的代码时,经常会发现模式中的变量的名字出现 (x:xs)或者(d:ds)。这是流行的命名约定。因为x包含列表的开头元素,xs包含剩下的元素列表,因此可以这样看:xs有一个“s”的结尾,可以当作x的“复数”形式。

通配模式

我们可以指出并不关心模式中某部分是什么值。用下划线"_"作为记号,称作通配符。像下面这样使用。

-- file: ch03/BookStore.hs
nicerID (Book id _ _ ) = id
nicerTitle (Book _ title _ ) = title
nicerAuthors (Book _ _ authors) = authors

这里,获得了之前定义的读取函数的简洁版本。现在,对每个函数使用哪个元素不会再有问题了。

在模式中,通配符就像变量一样,不过它不会绑定到新的变量上。就像上面的例子指出的那样,可以在一个单独的模式中使用超过一个通配符。

通配符的另一个优点是,如果我们在模式中引入了变量名而在函数体中没有使用时,Haskell的编译器会产生警告。定义了变量,但忘记使用,经常说明存在问题,因此这是一个有用的特性。如果使用通配符来代替不打算使用的变量的话,编译器就不会警告了。

模式完整性和通配符

在些一组模式时,很重要的一点是把类型的所有构造子都覆盖到。例如,要查看列表,需要一个匹配非空的构造子(:)的等式,和一个匹配空列表构造子[]的等式。

看下如果不能匹配所有情况会发生什么。这里,我们故意忽略对[]构造子的检查。

-- file: ch03/BadPattern.hs
badExample (x:xs) = x + badExample xs

如果应用一个无法匹配的值,将会产生一个运行时错误:我们的软件存在bug!

ghci> badExample []
*** Exception: BadPattern.hs:4:0-36: Non-exhaustive patterns in function badExample


在这个例子里,函数的定义中没有可以匹配[]值的等式。

[Tip] 不完整模式的警告

GHC提供了很有帮助的编译选项 -fwarn-incomplete-patterns ,在编译时如果模式序列不能匹配类型的所有构造子将会打印警告信息。

如果我们不关心构造子是什么,可以用通配符匹配来提供默认的行为。

-- file: ch03/BadPattern.hs
goodExample (x:xs) = x + goodExample xs
goodExample _ = 0

上面的通配符可以匹配 []构造子,因此应用这个函数不会导致程序崩溃。

ghci> goodExample []
0
ghci> goodExample [1,2]
3

记录语法

给data类型的每个分量写读取函数是重复性的无聊工作。

-- file: ch03/BookStore.hs
nicerID (Book id _ _ ) = id
nicerTitle (Book _ title _ ) = title
nicerAuthors (Book _ _ authors) = authors

我们称这种代码为“样板程序”:必要但占地且烦人。Haskell程序员不喜欢样板。幸运的是,语言解决了这个特别的样板问题:我们可以在定义data类型的同时给每个组分定义读取函数。(这里逗号的位置只是一种偏好,如果你喜欢,可以把他们放在每行的末尾而不是开头。)

-- file: ch03/BookStore.hs
data Customer = Customer {
customerID :: CustomerID
, customerName :: String
, customerAddress :: Address
} deriving (Show)

这几乎与下面这种更熟悉的形式的意思完全相同。

-- file: ch03/AltCustomer.hs
data Customer = Customer Int String [String]
deriving (Show)

customerID :: Customer -> Int
customerID (Customer id _ _) = id

customerName :: Customer -> String
customerName (Customer _ name _) = name

customerAddress :: Customer -> [String]
customerAddress (Customer _ _ address) = address

Haskell给我们的类型定义的每个成员用提供的名字创建了一个读取函数。

ghci> :type customerID
customerID :: Customer -> CustomerID

我们依然可以用常用的函数应用语法创建这种类型的值。

-- file: ch03/BookStore.hs
customer1 = Customer 271828 "J.R. Hacker"
["255 Syntax Ct",
"Milpitas, CA 95134",
"USA"]

记录语法提供了一种创建值的冗长的写法。有时这可以让代码更可读。

-- file: ch03/BookStore.hs
customer2 = Customer {
customerID = 271828
, customerAddress = ["1048576 Disk Drive",
"Milpitas, CA 95134",
"USA"]
, customerName = "Jane Q. Citizen"
}

如果用这种形式,成员的顺序可以变化。这里我们把name和address属性的位置从它们在类型声明时的位置上移开。

用记录语法定义类型时,也改变了打印这个类型的值的方式。


ghci> customer1
Customer {customerID = 271828, customerName = "J.R. Hacker", customerAddress = ["255 Syntax Ct","Milpitas, CA 95134","USA"]}

作为比较,看下BookInfo的值;我们没有用记录语法来定义它的类型。

ghci> cities
Book 173 "Use of Weapons" ["Iain M. Banks"]

通过使用记录语法“免费”获得读取函数实际上就是普通的Haskell函数。

ghci> :type customerName
customerName :: Customer -> String
ghci> customerName customer1
"J.R. Hacker"

System.Time标准模块中很好的应用了记录语法。这是这个模块中的一个类型定义:

data CalendarTime = CalendarTime {
ctYear :: Int,
ctMonth :: Month,
ctDay, ctHour, ctMin, ctSec :: Int,
ctPicosec :: Integer,
ctWDay :: Day,
ctYDay :: Int,
ctTZName :: String,
ctTZ :: Int,
ctIsDST :: Bool
}


如果没有记录语法,从这样一个类型中取出特定的成员将非常痛苦。这种写法使得处理大的结构更加容易。

参数化类型

我们反复提到列表的类型是多态的:列表的元素可以是任意类型。我们也可以给我们自己的类型增加多态性。通过把类型变量加到类型声明中。Prelude模块中定义了名为Maybe的类型:可以用它来表示一个可能存在或缺失的值,例如数据库记录行中的成员可能是空值null。

-- file: ch03/Nullable.hs
data Maybe a = Just a
| Nothing

这里变量a不是一个常规的变量:它是类型变量。它指出Maybe类型把另一个类型当作它的参数。这可以让Maybe用在任意类型的值上。

-- file: ch03/Nullable.hs
someBool = Just True

someString = Just "something"

和往常一样,可以用ghci来做试验。

ghci> Just 1.5
Just 1.5
ghci> Nothing
Nothing
ghci> :type Just "invisible bike"
Just "invisible bike" :: Maybe [Char]

Maybe是一个多态或者说泛型的类型。给Maybe的类型构造子一个参数来创建特定的类型,如 Maybe Int 或 Maybe [Bool]。如我们所愿,这些类型是相区别的。

可以把参数化类型嵌套使用,不过如果这样做的话需要使用括号来告诉Haskell编译器如何解析表达式。

-- file: ch03/Nullable.hs
wrapped = Just (Just "wrapped")

再拿熟悉的语言来做类比,参数化类型和C++中的模板以及Java中的泛型有些相像。只是要知道这这是一个很浅层的相似。模板和泛型都是在它们的语言最初定义出现后很久才被加进去的,用起来有些不太优雅。Haskell的参数化类型从一开始就是与语言一同被设计的,它更简单更容易使用。

递归类型

熟悉的列表类型是递归的:它用自身定义自身。要理解这个,让我们创建自己的列表类型。用 Cons 代替 (:) 构造子,用 Nil 代替 []。

-- file: ch03/ListADT.hs
data List a = Cons a (List a)
| Nil
deriving (Show)

因为List a 同时出现在了= 号的左右两侧,类型的定义引用了它自身。要用Cons构造子创建新的值,必须给他提供一个a类型值和一个 List a 类型的值。看下实际中的用法。

我们可以创建的最简单的List a类型的值是 Nil。把类型定义保存在一个文件中,在ghci中载入。

ghci> Nil
Nil

因为Nil是List类型,因此我们可以把它当成Cons的参数。

ghci> Cons 0 Nil
Cons 0 Nil

并且Cons 0 Nil 是List a类型,因此可以当作Cons的参数。

ghci> Cons 1 it
Cons 1 (Cons 0 Nil)
ghci> Cons 2 it
Cons 2 (Cons 1 (Cons 0 Nil))
ghci> Cons 3 it
Cons 3 (Cons 2 (Cons 1 (Cons 0 Nil)))

我们可以一直用这样的方式来创建无限长的Cons链,每个的最后都是一个Nil值。

[Tip] 我们的List是否是可用的列表?

我们可以简单的证明我们的List a类型与内建的列表[a]类型形状相同。为此,我们创建一个函数来把任意的[a]类型值创建成List a 类型值。

-- file: ch03/ListADT.hs
fromList (x:xs) = Cons x (fromList xs)
fromList [] = Nil

通过看代码,可以知道它把每一个(:)替换成 Cons,把每个 [] 替换成 Nil。这包含了内建列表的所有类型构造子。两个类型是同构的;它们形状相同。

ghci> fromList "durian"
Cons 'd' (Cons 'u' (Cons 'r' (Cons 'i' (Cons 'a' (Cons 'n' Nil)))))
ghci> fromList [Just True, Nothing, Just False]
Cons (Just True) (Cons Nothing (Cons (Just False) Nil))

递归类型的第三个例子,这是一个二叉树类型的定义。

-- file: ch03/Tree.hs
data Tree a = Node a (Tree a) (Tree a)
| Empty
deriving (Show)

一个二叉树要么是一个含有两个子节点的节点,子节点本身也是二叉树,要么是一个空值。

这一次把我们的定义与更加熟悉的语言中的相比较。这是Java中的相似的类定义。

class Tree<A>
{
A value;
Tree<A> left;
Tree<A> right;

public Tree(A v, Tree<A> l, Tree<A> r)
{
value = v;
left = l;
right = r;
}
}

一个主要的区别是在Java中可以用一个特殊的值null 表示“空的”,因此可以用null来表示一个节点没有左或右子结点。这里是一个创建两层的树的简单函数(一个没有子结点的节点称为叶子)。

class Example
{
static Tree<String> simpleTree()
{
return new Tree<String>(
"parent",
new Tree<String>("left leaf", null, null),
new Tree<String>("right leaf", null, null));
}
}

在Haskell里没有null的等价物。可以用Maybe类型来提供相似的效果,不过那会传播模式匹配。替代的,我们决定使用一个无参数的Empty构建子。在Java的例子中树构建子使用null的地方,Haskell中用Empty。

-- file: ch03/Tree.hs
simpleTree = Node "parent" (Node "left child" Empty Empty)
(Node "right child" Empty Empty)

练习

1. 给List类型写与fromList相对的函数: 读取 List a 类型值并生成一个 [a] 值的函数。

2. 像Java的例子那样,定义一个只有一个构建子的树类型。用Maybe类型来指代节点的子节点,而不是用Empty构建子。

报告错误

Haskell提供了标准函数 error :: String -> a ,可以在代码出现严重错误时调用。把要显示的错误信息字符串作为参数给它。它的类型签名看上去很古怪:如何在只给一个字符串参数的情况下生成任意类型的值?

它的结果类型是 a ,因此可以在任何地方调用,它总是获得正确的类型。然而它并不像普通的函数那样返回一个值:而是直接停止求值并打印我们给它的错误信息。

mySecond函数返回输入的列表的第二个元素,但是如果输入列表不够长的话将会失败。

-- file: ch03/MySecond.hs
mySecond :: [a] -> a

mySecond xs = if null (tail xs)
then error "list too short"
else head (tail xs)

和往常一样,在ghci中看下它如何实际工作。

ghci> mySecond "xi"
'i'
ghci> mySecond [2]
*** Exception: list too short
ghci> head (mySecond [[9]])
*** Exception: list too short

主意上面的第三种情况,我们尝试用mySecond的结果作为另一个函数的参数。求值也被中止并返回到ghci的提示符。这是使用error主要的缺点:它无法让调用者区分可修复的错误和真的有必要停止程序的严重错误。

已经看到过,模式匹配失败会导致类似的无法修复的错误。

ghci> mySecond []
*** Exception: Prelude.tail: empty list

更可控的方法
可以用Maybe类型来表示出现错误的可能性。

如果要指出一个操作已经失败,可以用Nothing构造子。否则,把我们的值用Just构造子包装。

看下如果通过返回Maybe 值代替调用error,mySecond函数的变化。

-- file: ch03/MySecond.hs
safeSecond :: [a] -> Maybe a

safeSecond [] = Nothing
safeSecond xs = if null (tail xs)
then Nothing
else Just (head (tail xs))

如果传入的列表太短,可以返回Nothing给调用者。这可以让调用者决定如何处理,而调用error则会强制崩溃。

ghci> safeSecond []
Nothing
ghci> safeSecond [1]
Nothing
ghci> safeSecond [1,2]
Just 2
ghci> safeSecond [1,2,3]
Just 2

回到早前的主题,使用模式匹配可以进一步提高这个函数的可读性。

-- file: ch03/MySecond.hs
tidySecond :: [a] -> Maybe a

tidySecond (_:x:_) = Just x
tidySecond _ = Nothing

第一个模式只有在列表至少包含两个元素时被匹配(含有两个列表构建子),并且把变量x绑定到列表的第二个元素上。如果第一个模式匹配失败,第二个模式会匹配。

引入局部变量
当需要时可以在函数体里用let表达式来引入新的局部变量。这是一个简单的函数用来确定是否借给特定用户一些钱。我们至少保留100元的钱,返回减去借出的数量后的帐户余额。

-- file: ch03/Lending.hs
lend amount balance = let reserve = 100
newBalance = balance - amount
in if balance < reserve
then Nothing
else Just newBalance

主意这里的 let 和 in 关键字,let开始了一个变量声明块,in 结束这个块。每一行引入一个新的变量。变量名在 = 左边,它所绑定的表达式在右边。

[Note] 特别说明

让我们再强调下用词:let 块中名字绑定到一个表达式,而不是一个值。因为Haskell是惰性语言,名字关联的表达式知道需要时才实际求值。在上面的例子里,如果没有达到储备值则不会计算 newBalance 的值。

在let 块中定义变量时,称它们为let绑定变量。它的意思只是简单说:在let块中绑定了这个变量。

同样,这里空格的使用是重要的。在“缩进规则和表达式中的空格”一节会详细讨论代码布局规则。

可以在let声明的块内或者在in 关键字后面跟的表达式中使用let块中声明的变量名字。

一般的 ,我们把代码中可以使用一个名字的空间称为名字的作用域。如果可以使用一个名字,则在其作用域内,否则就是超出其作用域。如果一个名字在源文件任意处都可见,则称它在顶层作用域。

遮盖
Shadowing

可以在一个表达式里把多个let块嵌套的使用。

-- file: ch03/NestedLets.hs
foo = let a = 1
in let b = 2
in a + b

在嵌套的的let表达式中重复一个变量名是完全合法的,但并不明智。

-- file: ch03/NestedLets.hs
bar = let x = 1
in ((let x = "foo" in x), x)

这里,内层的x隐藏或遮盖了外层的x。它有同样的名字,但类型和值不同。

ghci> bar
("foo",1)

也可以遮盖函数的参数,会导致更奇怪的结果。这个函数的类型是什么?

-- file: ch03/NestedLets.hs
quux a = let a = "foo"
in a ++ "eek!"

因为函数的参数 a 被 let绑定的 a 遮盖了,使得它从未在函数体中被使用,因此这个参数可以是任意类型的。

ghci> :type quux
quux :: t -> [Char]

[Tip] 编译器警告是我们的朋友

遮盖显然会造成混淆和可恶的bug,因此GHC有一个有用的 -fwarn-name-shadowing 选项。一旦选项生效,当出现名字遮盖时GHC便会打印警告信息。

where子句
可以用另一种机制来引入局部变量:where子句。where子句中的定义供它前面的代码使用。这里是用where替代let的lend函数。

-- file: ch03/Lending.hs
lend2 amount balance = if amount < reserve * 0.5
then Just newBalance
else Nothing
where reserve = 100
newBalance = balance - amount

尽管where子句开始看上去有些奇怪,但它对可读性帮助巨大。可以把读者的注意力集中到表达式重要细节上,把辅助的定义放到后面。过不了多久,在使用缺少where子句的语言时,你就会想念它们了。

和let表达式一样,在where子句中空格也很重要。很快会在“缩进规则和表达式中的空格”一节讨论更多布局规则。

局部函数,全局变量
你会注意到Haskell中定义变量的语法与定义函数的语法看上去非常像。在let和where块中存在这样的对等性:可以像定义局部变量一样简单的定义局部函数。

-- file: ch03/LocalFunction.hs
pluralise :: String -> [Int] -> [String]
pluralise word counts = map plural counts
where plural 0 = "no " ++ word ++ "s"
plural 1 = "one " ++ word
plural n = show n ++ " " ++ word ++ "s"

我们定义了一个局部函数 plural,由几个等式组成。局部变量可以使用它所在的作用域内的变量:这里使用了外部函数pluralise中定义的 word。在pluralise的定义里,map函数把局部函数plural应用到列表counts的每一个元素上,下一章将会再讨论map函数。

和函数一样,也可以在源文件的顶层作用域定义变量。

-- file: ch03/GlobalVariable.hs
itemName = "Weighted Companion Cube"

缩进规则和表达式中的空格

在lend和lend2的定义中,程序行左侧有长短不一的空白。这并非偶然,在Haskell里,空格是有含义的。通过布局表示结构有时称作缩进规则。

Haskell使用缩进作为解析代码段的提示。在源文件的开始,第一个顶层声明或定义可以从任意一列开始,Haskell编译器或解释器将记住此缩进位置。后续的所有顶层声明必须缩进到同样位置。

这是顶层缩进规则的演示。第一个文件 GoodIndent.hs是正确的。

-- file: ch03/GoodIndent.hs
-- This is the leftmost column.

-- It's fine for top-level declarations to start in any column...
firstGoodIndentation = 1

-- ...provided all subsequent declarations do, too!
secondGoodIndentation = 2

第二个 BadIndent.hs 没有遵守此规则。

-- file: ch03/BadIndent.hs
-- This is the leftmost column.

-- Our first declaration is in column 4.
firstBadIndentation = 1

-- Our second is left of the first, which is illegal!
secondBadIndentation = 2

这是用ghci尝试载入这量文件时的情形。

ghci> :load GoodIndent.hs
[1 of 1] Compiling Main ( GoodIndent.hs, interpreted )
Ok, modules loaded: Main.
ghci> :load BadIndent.hs
[1 of 1] Compiling Main ( BadIndent.hs, interpreted )

BadIndent.hs:8:2: parse error on input `secondBadIndentation'
Failed, modules loaded: none.

后续如果是空白行或者缩进更加靠右,则被当作当前项的延续,

let 表达式中的规则与where子句中相似。let 或 where关键字后,Haskell编译器或解释器会记住下一个记号出现的缩进位置。如果下一行是空白或者缩进更加靠右,被当成前一行的继续。如果缩进与前一个项目开始的位置相同,会被当作在同一个块中开始一个新的项目。

-- file: ch03/Indentation.hs
foo = let firstDefinition = blah blah
-- a comment-only line is treated as empty
continuation blah

-- we reduce the indentation, so this is a new definition
secondDefinition = yada yada

continuation yada
in whatever

这是let 和 where的嵌套使用。

-- file: ch03/letwhere.hs
bar = let b = 2
c = True
in let a = b
in (a, c)

名称a 只能在内层的let表达式中可见。在外层的let中不可见。如果试图在哪里使用它将产生编译错误。缩进能给我们和编译器一个可见的当前所在作用域的线索。

-- file: ch03/letwhere.hs
foo = x
where x = y
where y = 2

??类似的,第一个where子句的作用域是foo的定义,而第二个的作用域只是第一个where子句。
Similarly, the scope of the first where clause is the definition of foo, but the scope of the second is just the first where clause. No comments

我们用let 和 where子句的缩进来让我们的意图更易于描绘。

关于tab和空格

如果使用Haskell友好的编辑器(如Emacs),可能已经配置号在编辑Haskell源文件时全部空格来表示空白。如果你的编辑器对Haskell一无所知,那么需要配置成只使用空格。

这样做是出于移植性考虑。在使用等宽字体的编辑器里,tab所占宽度约定不同:在Unix类系统上是8个字符,而Windows系统上是4个。这意味着不管你个人认为tab应该多宽,你无法确定别人的编辑器会遵从你的设置。用tab做的任何缩进都会在某些人的配置下变得乱七八糟。实际上,这会导致编译问题,Haskell语言标准要求其实现使用Unix的tab宽度约定。用空格则可以完全避免此问题。

缩进规则不是必须的

可以用明确的结构而不是排版来说明意图。为此把等式块用花括号在开头结尾括起来,用分号分隔每一项。下面两个let的使用具有相同的含义。

-- file: ch03/Braces.hs
bar = let a = 1
b = 2
c = 3
in a + b + c

foo = let { a = 1; b = 2;
c = 3 }
in a + b + c

使用明确结构说明时,平常的排版规则不再起作用,因此第二个let表达式的不寻常的缩进可以被处理。

可以在平常使用排版的任何地方使用明确结构说明。可以在where子句中或者顶层声明中使用。不过要知道尽管存在这项功能,在实际的Haskell程序中很少见到使用明确结构说明的。

case表达式

不止可以在函数定义中使用模式匹配。case结构可以在表达里上进行模式匹配。就像这样。在Data.Maybe里定义的这个函数把Maybe的值拆包,如果值是Nothing则使用了默认值。

-- file: ch03/Guard.hs
fromMaybe defval wrapped =
case wrapped of
Nothing -> defval
Just value -> value

case关键字后跟任意的表达式:对这个表达式的结果进行模式匹配。of关键字标识出表达式的结束和模式匹配块的开始。

模式匹配块中每一项由一个模式, -> 箭头和一个匹配成功时执行的表达式,三部分组成。这些表达式必须具有相同的类型。是第一个匹配成功的模式关联的那个表达式的结果作为case表达式的结果。从上到下进行匹配尝试。

要表示“其他模式匹配都失败的话就执行此表达式”,只需要在模式的列表最后用通配模式 _ 。如果模式匹配都失败了,会得到一个早前看到的那种运行时错误。

初学者使用模式常犯的错误

初学Haskell的程序员有几种误解或误用模式的情况。这几种模式匹配的尝试会出错。它可能会出乎你的意料。

对变量不正确的匹配

-- file: ch03/BogusPattern.hs
data Fruit = Apple | Orange

apple = "apple"

orange = "orange"

whichFruit :: String -> Fruit

whichFruit f = case f of
apple -> Apple
orange -> Orange

乍看上去,这段代码尝试检查f的值,看它是否匹配apple 或 orange 的值。

把上面的代码重写成等价的形式会更容易看出错误所在。

-- file: ch03/BogusPattern.hs
equational apple = Apple
equational orange = Orange

现在看出问题了么?这里可以更明显的看出apple并不指向顶层的名为apple的值:它是一个局部的模式变量。


[Note] 恒等模式 Irrefutable patterns

把一个总是成功的模式称为恒等的。用普通变量名或者通配符 _ 都是恒等模式的例子。


这是这个函数的正确版本。

-- file: ch03/BogusPattern.hs
betterFruit f = case f of
"apple" -> Apple
"orange" -> Orange

用匹配字面量"apple"和 "orange"修复这个错误。

错误的相等性比较尝试

假如要比较Tree类型值的两个节点中存的值是否相当,如相等则返回其中一个,应该如何做?这是一个尝试。

-- file: ch03/BadTree.hs
bad_nodesAreSame (Node a _ _) (Node a _ _) = Just a
bad_nodesAreSame _ _ = Nothing

在模式的绑定中一个名称只能出现一次。不能通过把一个变量放在多个位置来表示“这几个值应该相等”。而是采用“守卫”(guard)来解决这个问题,它是Haskell中另一个极有价值的特性。

用守卫进行按条件求值

模式匹配限制我们只能侧思值的形状是否符合。虽然这很有用,不过我们经常想要在对函数体求值前进行更有表达能力的检查。Haskell提供了守卫特性提供此能力。我们修改比较树的两个节点的函数来介绍这个方法。

-- file: ch03/BadTree.hs
nodesAreSame (Node a _ _) (Node b _ _)
| a == b = Just a
nodesAreSame _ _ = Nothing

在这个例子里,用模式匹配保证我们查看的这些值的形状正确,用守卫来比较它们中的部分值。


模式后面可以跟零个或多个守卫,每一个都是Bool类型的表达式。用 | 符号来引出一个守卫。守卫表达式后面跟一个 = 号(在case表达式中是 -> ),然后是当守卫表达式求值为True时使用的程序体。如果一个模式匹配成功,与他相关联的每个守卫依书写顺序依次求值。如果有一个守卫成功,这个守卫相连的程序体讲作为函数的结果。如果没有任何守卫成功,模式匹配将转到下一个模式。

当守卫表达式求值时,它所关联的模式中声明的所有变量都可被绑定使用。

这是用守卫重写的lend函数。

-- file: ch03/Lending.hs
lend3 amount balance
| amount <= 0 = Nothing
| amount > reserve * 0.5 = Nothing
| otherwise = Just newBalance
where reserve = 100
newBalance = balance - amount

看上去有些特别的守卫表达式 otherwise 只是一个绑定到True值的简单变量,可以提高可读性。

任何可用模式的地方都可以使用守卫。通过用模式匹配和守卫写一系列等式来定义函数可以更清晰。还记得“条件求值”一节中定义的 myDrop 函数么?

-- file: ch02/myDrop.hs
myDrop n xs = if n <= 0 || null xs
then xs
else myDrop (n - 1) (tail xs)

这是用模式匹配和守卫重写的格式。

-- file: ch02/myDrop.hs
niceDrop n xs | n <= 0 = xs
niceDrop _ [] = []
niceDrop n (_:xs) = niceDrop (n - 1) xs

这个风格上的变化可以让我们枚举一个函数可能的行为。如果把各种决定用if表达式埋在函数内部,代码将更难以阅读。

练习


1. 写一个函数计算列表中元素的个数。要进行测试的话,可以看是否与标准的 length 函数给出的结果相同。

2. 给源文件中的函数增加类型签名。通过在ghci中重新载入源文件来测试。

3. 写一个函数计算列表的平均值,及用列表元素的和除以它的长度。(需要使用 fromIntegral 函数来把列表的长度从整数转换成浮点数。)

4. 把列表变成回文形式,即从前向后或从后向前读都一样。例如,输入列表 [1,2,3],你的函数应该输出 [1,2,3,3,2,1]。

5. 写一个函数判断输入的列表是否是回文的。

6. 创建一个函数把一个列表的列表按照每个子列表的长度排序。(可以看下 Data.List 模块中的sortBy函数)

7. 定义一个函数把一个列表的列表连接在一起,中间用一个值分隔。

-- file: ch03/Intersperse.hs
intersperse :: a -> [[a]] -> [a]

分隔值应该出现在列表元素之间,但不出现在最后一个元素后。这个函数要像下面这样工作。

ghci> :load Intersperse
[1 of 1] Compiling Main ( Intersperse.hs, interpreted )
Ok, modules loaded: Main.
ghci> intersperse ',' []
""
ghci> intersperse ',' ["foo"]
"foo"
ghci> intersperse ',' ["foo","bar","baz","quux"]
"foo,bar,baz,quux"

8. 使用本章早前定义的二叉树类型,写一个函数来确定树的高度。树的高度是从根节点到Empty的枝杈中数目最大的那个。例如,树 Empty 的高度是0; Node "x" Empty Empty的高度是1; Node "x" Empty (Node "y" Empty Empty) 高度是2,以此类推。

9. 考虑三个二维点: a,b和c。如果从线段a-b看过去,线段b-c的角度要么是转左,转右或者变成直线。定义一个 Direction 数据类型来表示这几种可能性。

10. 写一个函数来计算由三个二维点确定的转动方向,返回 Direction 值。

11. 定义一个函数,读取一个二维点的列表,依次计算相连的每三个点的方向。给出点的列表 [a,b,c,d,e],会先计算 [a,b,c]的转向,然后是 [b,c,d]的转向,之后是 [c,d,e]。函数应返回 Direction 的列表。

??
12. 使用前面三个练习的代码,实现Graham。可以在Wikipedia上找到
12.Using the code from the preceding three exercises, implement Graham's scan algorithm for the convex hull of a set of 2D points. You can find good description of what a convex hull. is, and how the Graham scan algorithm should work, on Wikipedia. 5 comments

[7] 如果熟悉 C 或 C++ 的话,它有点类似于 typedef。

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics