什么是函数式语言中的“模式匹配”?

What is 'Pattern Matching' in functional languages?

我正在阅读关于函数式编程的文章,我注意到在许多文章中提到模式匹配是函数式语言的核心特性之一。

有人能解释一下Java/C++/JavaScript开发人员是什么意思吗?


了解模式匹配需要解释三个部分:

  • 代数数据类型。
  • 什么模式匹配
  • 为什么很棒。
  • 代数数据类型简而言之

    类似于ML的函数语言允许您定义称为"分离联合"或"代数数据类型"的简单数据类型。这些数据结构是简单的容器,可以递归定义。例如:

    1
    2
    3
    type 'a list =
        | Nil
        | Cons of 'a * 'a list

    定义类似堆栈的数据结构。把它当作这个c:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public abstract class List<T>
    {
        public class Nil : List<T> { }
        public class Cons : List<T>
        {
            public readonly T Item1;
            public readonly List<T> Item2;
            public Cons(T item1, List<T> item2)
            {
                this.Item1 = item1;
                this.Item2 = item2;
            }
        }
    }

    因此,ConsNil标识符定义了简单的类,其中of x * y * z * ...定义了一个构造函数和一些数据类型。构造函数的参数未命名,它们由位置和数据类型标识。

    您创建a list类的实例,如下所示:

    1
    let x = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))

    同:

    1
    Stack<int> x = new Cons(1, new Cons(2, new Cons(3, new Cons(4, new Nil()))));

    简而言之,模式匹配

    模式匹配是一种类型测试。所以假设我们创建了一个类似上面的堆栈对象,我们可以实现方法来查看和弹出堆栈,如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    let peek s =
        match s with
        | Cons(hd, tl) -> hd
        | Nil -> failwith"Empty stack"

    let pop s =
        match s with
        | Cons(hd, tl) -> tl
        | Nil -> failwith"Empty stack"

    上述方法与以下c等效(尽管没有实现):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    public static T Peek<T>(Stack<T> s)
    {
        if (s is Stack<T>.Cons)
        {
            T hd = ((Stack<T>.Cons)s).Item1;
            Stack<T> tl = ((Stack<T>.Cons)s).Item2;
            return hd;
        }
        else if (s is Stack<T>.Nil)
            throw new Exception("Empty stack");
        else
            throw new MatchFailureException();
    }

    public static Stack<T> Pop<T>(Stack<T> s)
    {
        if (s is Stack<T>.Cons)
        {
            T hd = ((Stack<T>.Cons)s).Item1;
            Stack<T> tl = ((Stack<T>.Cons)s).Item2;
            return tl;
        }
        else if (s is Stack<T>.Nil)
            throw new Exception("Empty stack");
        else
            throw new MatchFailureException();
    }

    (几乎总是,ML语言实现模式匹配而不需要运行时类型测试或强制转换,因此C代码有点欺骗性。让我们用手轻轻地挥动一下,把实现细节擦掉。)

    数据结构分解简而言之

    好,我们回到Peek方法:

    1
    2
    3
    4
    let peek s =
        match s with
        | Cons(hd, tl) -> hd
        | Nil -> failwith"Empty stack"

    诀窍是理解hdtl标识符是变量(errm…因为它们是不可变的,所以它们不是真正的"变量",而是"值")。如果s具有Cons类型,那么我们将从构造函数中提取它的值,并将它们绑定到名为hdtl的变量。

    模式匹配很有用,因为它允许我们通过数据结构的形状而不是内容来分解数据结构。所以假设我们定义一个二叉树如下:

    1
    2
    3
    type 'a tree =
        | Node of 'a tree * 'a * 'a tree
        | Nil

    我们可以定义一些树的旋转,如下所示:

    1
    2
    3
    4
    5
    6
    7
    let rotateLeft = function
        | Node(a, p, Node(b, q, c)) -> Node(Node(a, p, b), q, c)
        | x -> x

    let rotateRight = function
        | Node(Node(a, p, b), q, c) -> Node(a, p, Node(b, q, c))
        | x -> x

    (let rotateRight = function构造器是let rotateRight s = match s with ...的语法糖。)

    因此,除了将数据结构绑定到变量之外,我们还可以深入研究它。假设我们有一个节点let x = Node(Nil, 1, Nil)。如果我们称之为rotateLeft x,我们将x与第一个模式进行比较,因为右边的孩子有Nil类型而不是Node类型,所以无法匹配。它将移动到下一个模式,x -> x,它将匹配任何输入并返回未修改的输入。

    为了进行比较,我们将上述方法写在C中,如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    public abstract class Tree<T>
    {
        public abstract U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc);

        public class Nil : Tree<T>
        {
            public override U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc)
            {
                return nilFunc();
            }
        }

        public class Node : Tree<T>
        {
            readonly Tree<T> Left;
            readonly T Value;
            readonly Tree<T> Right;

            public Node(Tree<T> left, T value, Tree<T> right)
            {
                this.Left = left;
                this.Value = value;
                this.Right = right;
            }

            public override U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc)
            {
                return nodeFunc(Left, Value, Right);
            }
        }

        public static Tree<T> RotateLeft(Tree<T> t)
        {
            return t.Match(
                () => t,
                (l, x, r) => r.Match(
                    () => t,
                    (rl, rx, rr) => new Node(new Node(l, x, rl), rx, rr))));
        }

        public static Tree<T> RotateRight(Tree<T> t)
        {
            return t.Match(
                () => t,
                (l, x, r) => l.Match(
                    () => t,
                    (ll, lx, lr) => new Node(ll, lx, new Node(lr, x, r))));
        }
    }

    认真对待。

    模式匹配太棒了

    您可以使用访问者模式在C中实现类似于模式匹配的功能,但由于不能有效地分解复杂的数据结构,因此它几乎没有那么灵活。此外,如果您使用的是模式匹配,编译器会告诉您是否遗漏了一个案例。这有多棒?

    考虑一下如何在C语言或没有模式匹配的语言中实现类似的功能。想想在运行时如何在没有测试和强制转换的情况下完成它。当然不难,只是笨重和笨重。你没有编译器检查来确保你已经覆盖了每一个案例。

    因此模式匹配可以帮助您以一种非常方便、简洁的语法来分解和导航数据结构,它使编译器能够至少检查一点代码的逻辑。这真是一个杀手级的特色。


    简而言之:模式匹配是因为函数语言将等号视为等价的断言,而不是赋值。

    答案很长:模式匹配是一种基于给定值的"形状"的分派形式。在函数语言中,您定义的数据类型通常是所谓的区分联合或代数数据类型。例如,什么是(链接的)列表?某一类a事物的链表List要么是空表Nil,要么是aCons类型的元素,放在List a上(a的列表)。在haskell(我最熟悉的功能语言)中,我们写了这个

    1
    2
    data List a = Nil
                | Cons a (List a)

    所有区分的联合都是这样定义的:一个类型有固定数量的不同的创建方法;这里的创建者,如NilCons,称为构造函数。这意味着类型List a的值可以由两个不同的构造函数创建,它可以有两个不同的形状。所以假设我们想编写一个head函数来获取列表的第一个元素。在哈斯克尔,我们写这个

    1
    2
    3
    4
    5
    6
    -- `head` is a function from a `List a` to an `a`.
    head :: List a -> a
    -- An empty list has no first item, so we raise an error.
    head Nil        = error"empty list"
    -- If we are given a `Cons`, we only want the first part; that's the list's head.
    head (Cons h _) = h

    由于List a值可以是两种不同的类型,我们需要分别处理每种值;这是模式匹配。在head x中,如果x与模式Nil匹配,则运行第一个案例;如果它与模式Cons h _匹配,则运行第二个案例。

    简短的回答,解释说:我认为思考这种行为的最好方法之一就是改变你对等号的看法。在大括号语言中,大体上,=表示赋值:a = b表示"使a变为b"。在许多函数语言中,=表示相等的断言:let Cons a (Cons b Nil) = frob x表示左边的东西,Cons a (Cons b Nil)相当于右边,frob x;另外,左边使用的所有变量都变为可见。这也是函数参数的情况:我们断言第一个参数看起来像Nil,如果不是,我们会继续检查。


    这意味着不是写作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    double f(int x, int y) {
      if (y == 0) {
        if (x == 0)
          return NaN;
        else if (x > 0)
          return Infinity;
        else
          return -Infinity;
      } else
         return (double)x / y;
    }

    你可以写

    1
    2
    3
    4
    f(0, 0) = NaN;
    f(x, 0) | x > 0 = Infinity;
            | else  = -Infinity;
    f(x, y) = (double)x / y;

    另外,C++也支持模式匹配。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    static const int PositiveInfinity = -1;
    static const int NegativeInfinity = -2;
    static const int NaN = -3;

    template <int x, int y> struct Divide {
      enum { value = x / y };
    };
    template <bool x_gt_0> struct aux { enum { value = PositiveInfinity }; };
    template <> struct aux<false> { enum { value = NegativeInfinity }; };
    template <int x> struct Divide<x, 0> {
      enum { value = aux<(x>0)>::value };
    };
    template <> struct Divide<0, 0> {
      enum { value = NaN };
    };

    #include <cstdio>

    int main () {
        printf("%d %d %d %d
    ", Divide<7,2>::value, Divide<1,0>::value, Divide<0,0>::value, Divide<-1,0>::value);
        return 0;
    };


    模式匹配有点像类固醇的过载方法。最简单的情况与Java中看到的情况大致相同,参数是带有名称的类型的列表。要调用的正确方法是基于传入的参数,并且它同时作为这些参数对参数名的赋值。

    模式只是更进一步,并且可以进一步破坏传入的参数。它还可以潜在地使用保护来根据参数的值进行实际匹配。为了演示,我将假装JavaScript有模式匹配。

    1
    2
    3
    function foo(a,b,c){} //no pattern matching, just a list of arguments

    function foo2([a],{prop1:d,prop2:e}, 35){} //invented pattern matching in JavaScript

    在foo2中,它期望a是一个数组,它分解第二个参数,期望一个具有两个props(prop1,prop2)的对象,并将这些属性的值赋给变量d和e,然后期望第三个参数为35。

    与JavaScript不同,模式匹配的语言通常允许具有相同名称但不同模式的多个函数。这样,它就像方法重载一样。我在二郎举个例子:

    1
    2
    3
    fibo(0) -> 0 ;
    fibo(1) -> 1 ;
    fibo(N) when N > 0 -> fibo(N-1) + fibo(N-2) .

    稍微模糊一下你的眼睛,你可以在javascript中想象到这一点。可能是这样的:

    1
    2
    3
    function fibo(0){return 0;}
    function fibo(1){return 1;}
    function fibo(N) when N > 0 {return fibo(N-1) + fibo(N-2);}

    点是,当你调用FiBO时,它使用的实现是基于参数的,但是当Java仅限于类型作为过载的唯一手段时,模式匹配可以做得更多。

    除了这里所示的函数重载之外,同样的原理还可以应用于其他地方,例如case语句或析构函数赋值。Javascript甚至在1.7中有这个功能。


    模式匹配允许您根据某些模式匹配值(或对象),以选择代码分支。从C++的观点来看,它听起来有点类似于EDCOX1×25Ω语句。在函数语言中,模式匹配可用于对标准原语值(如整数)进行匹配。但是,它对组合类型更有用。

    首先,让我们演示基本值上的模式匹配(使用扩展的伪C++switch):

    1
    2
    3
    4
    5
    6
    7
    8
    switch(num) {
      case 1:
        // runs this when num == 1
      case n when n > 10:
        // runs this when num > 10
      case _:
        // runs this for all other cases (underscore means 'match all')
    }

    第二种用法处理诸如元组(允许在单个值中存储多个对象)之类的函数数据类型,以及区分联合(允许创建可以包含多个选项之一的类型)。这听起来有点像enum,只是每个标签也可以携带一些值。在伪C++语法中:

    1
    2
    3
    4
    enum Shape {
      Rectangle of { int left, int top, int width, int height }
      Circle of { int x, int y, int radius }
    }

    Shape类型的值现在可以包含所有坐标的Rectangle,或者包含中心和半径的Circle。模式匹配允许您编写用于处理Shape类型的函数:

    1
    2
    3
    4
    5
    6
    7
    switch(shape) {
      case Rectangle(l, t, w, h):
        // declares variables l, t, w, h and assigns properties
        // of the rectangle value to the new variables
      case Circle(x, y, r):
        // this branch is run for circles (properties are assigned to variables)
    }

    最后,您还可以使用结合这两个特性的嵌套模式。例如,可以使用Circle(0, 0, radius)来匹配所有中心位于点[0,0]且具有任何半径的形状(半径的值将分配给新变量radius)。

    这可能听起来有点陌生,从C++的观点来看,但我希望我的伪C++解释清楚。函数式编程基于完全不同的概念,因此在函数式语言中它更有意义!


    模式匹配是语言的解释器根据所给参数的结构和内容选择特定函数的地方。

    它不仅是一种功能性语言功能,而且适用于许多不同的语言。

    我第一次发现这个想法是在我学习prolog的时候,它实际上是语言的核心。

    例如

    last([LastItem], LastItem).

    last([Head|Tail], LastItem) :-
    last(Tail, LastItem).

    上面的代码将给出列表的最后一项。输入arg是第一个,结果是第二个。

    如果列表中只有一个项目,解释器将选择第一个版本,第二个参数将设置为等于第一个,即为结果分配一个值。

    如果列表同时有头部和尾部,则解释器将选择第二个版本并重复执行,直到列表中只剩下一个项目为止。


    对于许多人来说,如果提供了一些简单的例子,那么学习新概念就更容易了,下面我们将介绍:

    假设您有一个三个整数的列表,并且想要添加第一个和第三个元素。如果没有模式匹配,您可以这样做(haskell中的示例):

    1
    2
    3
    Prelude> let is = [1,2,3]
    Prelude> head is + is !! 2
    4

    现在,虽然这是一个玩具例子,但假设我们希望将第一个和第三个整数绑定到变量并求和:

    1
    2
    3
    4
    addFirstAndThird is =
        let first = head is
            third = is !! 3
        in first + third

    从数据结构中提取值就是模式匹配所做的。您基本上"镜像"了某个东西的结构,为感兴趣的地方提供变量绑定:

    1
    addFirstAndThird [first,_,third] = first + third

    当您使用[1,2,3]作为参数调用此函数时,[1,2,3]将与[First,_,Third]统一,首先绑定到1,第三绑定到3,丢弃2(_是您不关心的内容的占位符)。

    现在,如果只想将列表与2作为第二个元素进行匹配,可以这样做:

    1
    addFirstAndThird [first,2,third] = first + third

    这只对第二个元素为2的列表有效,否则会引发异常,因为没有为不匹配的列表提供addFirstAndHird的定义。

    到目前为止,我们只使用模式匹配来破坏绑定。除此之外,您可以为同一个函数提供多个定义,其中使用了第一个匹配定义,因此,模式匹配有点像"立体图上的开关语句":

    1
    2
    addFirstAndThird [first,2,third] = first + third
    addFirstAndThird _ = 0

    addFirstAndHird将很高兴地添加列表的第一和第三个元素,其中2作为第二个元素,否则将"fall through"和"return"0。这种"类似开关"的功能不仅可以用于功能定义,例如:

    1
    2
    3
    4
    Prelude> case [1,3,3] of [a,2,c] -> a+c; _ -> 0
    0
    Prelude> case [1,2,3] of [a,2,c] -> a+c; _ -> 0
    4

    此外,它不限于列表,但也可以与其他类型一起使用,例如匹配maybe类型的just和nothing值构造函数,以便"展开"该值:

    1
    2
    3
    4
    Prelude> case (Just 1) of (Just x) -> succ x; Nothing -> 0
    2
    Prelude> case Nothing of (Just x) -> succ x; Nothing -> 0
    0

    当然,这些只是玩具的例子,我甚至没有试图给出正式或详尽的解释,但它们应该足以掌握基本概念。


    你应该从维基百科页面开始,它给出了一个很好的解释。然后,阅读haskell wikibook的相关章节。

    这是一个很好的定义,从上面的wikibook:

    So pattern matching is a way of
    assigning names to things (or binding
    those names to those things), and
    possibly breaking down expressions
    into subexpressions at the same time
    (as we did with the list in the
    definition of map).


    下面是一个非常简短的示例,展示了模式匹配的有用性:

    假设您要对列表中的元素进行排序:

    1
    ["Venice","Paris","New York","Amsterdam"]

    收件人(我已将"纽约"分类)

    1
    ["Venice","New York","Paris","Amsterdam"]

    你可以用一种命令式的语言写:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    function up(city, cities){  
        for(var i = 0; i < cities.length; i++){
            if(cities[i] === city && i > 0){
                var prev = cities[i-1];
                cities[i-1] = city;
                cities[i] = prev;
            }
        }
        return cities;
    }

    在函数语言中,您可以编写:

    1
    2
    3
    4
    5
    let up list value =  
        match list with
            | [] -> []
            | previous::current::tail when current = value ->  current::previous::tail
            | current::tail -> current::(up tail value)

    正如您所看到的,模式匹配的解决方案具有较少的噪声,您可以清楚地看到不同的情况,以及旅行和取消我们的列表结构是多么容易。

    我在这里写了一篇更详细的博客文章。