• 闭包解析(Fun with closure)


    我发现英文标题真的非常不给力。

    这篇随笔是对“闭包”这个东西的简单介绍。为了轻松一些,用了Fun with closure这个标题。

    有点儿像闭包的东西

    我先找了几个有点儿像闭包的东西。摆出来看看。第一个东西是C++的Functor:

     1 struct add_x {
     2     add_x(int x) : m_x(x) { }
     3     int operator() (int y) { return m_x + y; }
     4  
     5 private:
     6     int m_x;
     7 };
     8 
     9 int value = 1;
    10 
    11 std::transform(input, input + size, result, add_x(value));

    这段代码期望将 input 集合中的每一个元素使用 add_x 映射到 result 集合中。这里,add_x是一个 functor。为了将在函数栈空间上定义的变量value引入到functor中来,我们必须采用成员变量的方式对其进行复制(或者引用)。这样一来,好像在栈上定义的值value被带到了另外一个上下文中一样。

    我们再来看看一段 C# 的代码:

     1 IEnumerable<int> Transform(
     2     IEnumerable<int> input,
     3     Func<int, int, int> transformer, 
     4     int factor) {
     5     foreach (int value in input) {
     6         yield return transformer(value, factor);
     7     }
     8 }
     9  
    10 int Add(int x, int y) { return x + y; }
    11  
    12 void Main() {
    13   int[] array = { 1, 2, 3, 4, 5 };
    14   int factor = 1;
    15   Transform(array, Add, factor).Dump();
    16 }

    这段代码同样也是在一个集合上应用 Add 方法。为了将在 Main 函数中定义的变量 factor 引入到Add方法中,我们将factor变量作为参数传入了Transform函数中,进而传入了transformer委托中。

    做一个闭包

    上面两段代码都像是“闭包”但是他们不是。我们接下来要做一个“真的”闭包,用C#吧,虽然我很想用Javascript。

    第一件事情就是将“函数”看作 first-class data,或者称之为first-class function。什么是 first-class function呢?请看维基(http://en.wikipedia.org/wiki/First-class_function),如果你不喜英文我简要解释:first-class function意味着在语言中,函数可以被用作参数传递到其他的函数中;函数可以当作返回值被其他函数返回;函数可以作为数据存储在其他数据结构中。好的我们现在就把函数看作 first-class function:

    1 Func<string, string, bool> predicator = delegate(string value, string part) {
    2   return value.Contains(part);
    3 };

    当然我们还可以将其写为 lambda 表达式:

    1 Func<string, string, bool> predicator = (value, part) => value.Contains(part);

    现在,如果我们希望知道一个字符串是否包含了 “jumps”这个字符串的时候,我们可以用如下的代码:

    string data = "A quick brown fox jumps over a lazy dog.";
    predicator(data, "jumps")

    但是我们不太喜欢“jumps”这个参数,我们从参数表中解放他,于是我们把他挪到了外面作为一个变量,而在函数数据体中直接使用这个变量。

    1 string partVariable = "jumps";
    2 Func<string, bool> predicator = (value) => value.Contains(partVariable);
    3 string data = "A quick brown fox jumps over a lazy dog.";
    4 predicator(data).Dump();

    现在你得到了闭包!恭喜。

    什么是闭包?

    那么什么是闭包呢?这里有两个定义。我们先来看睡觉前专用的定义:在计算机科学中(而不是数学中),一个闭包是一个函数或者一个函数的引用,以及他们所引用的环境信息(就像是一个表,这个表存储了这个函数中引用的每一个没有在函数内声明的变量)。

    也就是闭包总是要有两个部分的,一部分是一个函数,另一个部分是被这个函数“带走”的,但是却不是在这个函数中声明的变量表(称之为 free variables 或者 outer variables)。

    还有一个不是那么呆的定义:闭包允许你封装一些行为(函数就是行为),像其他对象一样将它传来传去(函数是first-class function),但是不论怎样,它仍然保持着对原来最初上下文的访问能力(它还能访问到 outer variables)。

    很神奇,那么他是怎么实现的呢?

    我们以C#为例,但是其他语言的实现方式大同小异。这里可能C++的实现需要注意问题最多,我们会单独的说明。C#代码来也:

    1 string key = "u";
    2 var result = words.Where(word => word.Contains(key));

    这是一段非常简单的代码,你可以编译,然后用反编译器反向一下就会看到编译器帮你做的事情,我把这些事情用以下的图表示:

    编译器为我们做了两件事情:

    (1)刚才提到闭包有两个要素,一个是函数,另一个是函数引用的外部变量。OK,这里函数就是 word => word.Contains(key),而外部变量就是 key。编译器将这两个东西封装成了一个类:ClosureHelper。
    (2)将原本在函数“栈”上分配的变量 key,替换为了 closureHelper.key。此时,变量就跑到堆上去了。所以即使函数满世界跑,他也总能够访问到最初的那个变量closureHelper.key。

    看到了吗?这个变量的生存期实际上延长了!

    Closure的“诡异”现象

    在了解了实现细节之后。我们可以来探讨一下使用 Closure 可能出现的“诡异”现象。说“诡异”其实只要套用 Closure 的实现细节,他们实际上也很普通。这些诡异现象的成因基本上都是一个:outer-variable在closure中被改变了。

    例子1:

    假设我们有如下的初始代码:

    1 var words = new List<string> {
    2     "the", "quick", "brown", "fox", "jump", 
    3     "over", "a", "lazy", "dog"
    4 };
    5  
    6 string key = "u";
    7 var result = words.Where(word => word.Contains(key));

    我们比较容易知道输出是:quick和jump。但是如果这个程序变成:

    1 string key = "u";
    2 Func<string, bool> predicate = word => word.Contains(key);
    3 key = "v";
    4 
    5 var result = words.Where(predicate);

    那么输出又是什么呢?考虑到key实际上是closureHelper.key那么很容易知道在predicate执行的时候,key已经变成了"v",因此输出是:over。还想不明白的打开一个LINQPad试一下就知道了:-)。

    例子2:

     1 var actionList = new List<Action>();
     2  
     3 for (int i = 0; i < 5; ++i) {
     4     actionList.Add(
     5         () => Console.WriteLine(i));
     6 }
     7  
     8 foreach (Action action in actionList) {
     9     action();
    10 }

    如果你面试,也许会碰到这个东西。他的输出是:5 5 5 5 5。这个用语言解释起来不太容易,请看下面的图:

    ClosureHelper是在 for 循环体之外创建的,也就是 outer-variable 被 capture 的时候,全局只有一个实例。因此i实际上在第一个循环之后其值是5。这样,在action真正执行的时候只可能输出5。

    为了修正这个问题,我们不应当用 i 作为 outer variable 而是应当在循环体内定义 outer-variable:

     1 var actionList = new List<Action>();
     2  
     3 for (int i = 0; i < 5; ++i) {
     4     int outerVariable = i;
     5     actionList.Add(
     6         () => Console.WriteLine(outerVariable));
     7 }
     8  
     9 foreach (Action action in actionList) {
    10     action();
    11 
    12 }

    这样,执行过程就变成了:

    输出为期望值:0 1 2 3 4。

    事实上,如果是 java,根本不允许第一种写法。属于语法错误。

    例子3

    不难想到,在closure中改变outer variable同样可以影响到其他上下文中的outer variable引用。例如:

    1 int variable = 2;
    2  
    3 Action action = delegate { variable = 3; };
    4 action();

    执行之后,variable 的值是3。

    你看到了,在closure中改变outer varaible的值还是不要做为好。实际上,不更改 closure 中 outer variable 的值有额外的好处:

    (1)避免过度用脑导致的脱发;
    (2)这类代码更容易移植到函数式语言,例如 F# 等。因为在这些语言中 immutable 是一个基本的规则。

    关于函数式语言的一些范式已经超出了本文的范围,我建议大家看看以下的博客:

    (1)http://diditwith.net/default.aspx
    (2)http://blogs.msdn.com/b/dsyme/

    C++ 的细节

    方才提到了,由于闭包使得被 capture 的变量的生存期实际上延长了!这种处理方式对于C#,Java,F#等托管环境下的语言来说是没有什么问题的。但是C++(Native,对不起我真的讨厌用 C++ CLI 写程序)没有垃圾收集器。编译器怎么处理?难道也会延长生存期?答案是,不会。你需要自己搞定这些,否则没准儿就会出现 Access Violation。

    那么我怎么搞定呢?答案是控制 Capture Style。也就是向编译器说明,我如何引用 outer variable。我们先看看 C++ 中如何构造闭包吧。

    C++中的闭包声明可以用 lambda表达式来做,其包含三个部分:

    (1)Capture Method,也就是我们关注的capture style;
    (2)Parameter List,即参数表,和普通的 C/C++ 函数一样;
    (3)Expression Body:即函数的主体,和普通的 C/C++ 函数一样;

    第(2)和第(3)点都不用多说。关键是第一点。第一点要想说清楚真的要说不少废话,不如列表来的清晰,这个列表来源于 http://www.cprogramming.com/c++11/c++11-lambda-closures.html

    [] 什么都不捕获
    [&] 按照引用捕获所有的outer variables
    [=] 通过复制(按值)捕获所有的outer variables
    [=, &foo] 通过复制捕获所有的outer variables,但是对于 foo 这个变量,用引用捕获
    [bar] 通过复制捕获bar这个变量,其他的变量都不要复制;
    [this] 通过复制的方式捕获当前上下文中的this指针;

    这种Capture方法的指定直接影响到了编译器生成的Helper类型的成员变量的声明形式(声明为值还是引用)进而影响程序的逻辑。Helper类型将在Capture时生成,届时将根据Capture的类型进行复制或者引用。举一个例子。

    1 {
    2     outer_variable v; // [1]
    3  
    4     std::function<void(void)> lambda = [=] () { v.do_something(); }; // [2]
    5     lambda(); // [3]
    6 }

    在【1】处,outer_variable创建了一个实例,outer_variable 的默认构造函数被调用。假设我们记这个实例为 v。

    在【2】处比较繁:
    首先,一个 closure 实例被创建,并且 v 以 value 的形式进行 capture 被 closure 实例使用,因而 outer_variable 的复制构造函数被调用。我们记这个 outer_variable 的实例为 v'。
    其次,触发 std::function::ctor(const T&),其内部会为类型T(目前这里是一个匿名的 closure 类型)进行复制构造,于是,v' 作为其中的一个按值引用的成员变量也被复制构造,因此 outer_variable 的复制构造函数被调用。我们记这个 outer_variable 的实例为 v''。

    【2】完毕之后,rvalue 的 closure 实例被析构,使得 v' 被析构。

    【3】实际上调用的是 v'' 的 do_something 方法;

    是不是很烦?当然,在按值 capture 的方式下,显然无法更改 outer varaible 的值。

    按引用 capture 显然不需要频繁的复制构造 outer varaible 实例。并且,你可以在 closure 中更改 outer variable 的值以影响最初上下文中的变量。但是需要特别注意变量的生存期。

    std::function<void(void)> func;
     
    {
        outer_variable v; // [1]
        func = [&] () { v.do_something(); }; // [2]
    } // [3]
     
    func(); // undefined behavior.

    【1】outer_variable 默认构造函数调用,创建实例 v。
    【2】closure helper 实例构造,按引用 capture 到 v,由于是按引用因此没有复制构造函数调用,closure helper 实例使用 std::function 的构造函数初始化 std::function 对象。rvalue closure 实例析构。
    【3】由于超出了作用域,v析构。此时 func 对象的 closure helper 实例 capture 到的 v 的引用已然不存在了。

    此时调用 func 会造成未定义行为。具体的参见 C++ Spec:

    5.1.2 Lambda expressions [expr.prim.lambda]

    22 - [ Note: If an entity is implicitly or explicitly captured by reference, invoking the function call operator of the corresponding lambda-expression after the lifetime of the entity has ended is likely to result in undefined behavior. —end note ]

    结尾

    好了,写完了。希望到此你已经对 closure 有了一个了解,知道了编译器是怎么处理他的。也知道了使用 closure 的一些坑。如果你发现本文有什么地方不妥,就狠狠的砸过来把,欢迎讨论:-)。

  • 相关阅读:
    [php]php时间戳当中关于时区的问题
    [jQuery] jQuery如何获取同一个类标签的所有的值
    sed 命令基础
    Docker 学习第6课
    Docker 学习第五课
    Docker 学习第四课
    Docker 学习第三课
    Docker 学习第二课
    Docker学习第一课
    XdeBug的使用
  • 原文地址:https://www.cnblogs.com/lxconan/p/fun_with_closure.html
Copyright © 2020-2023  润新知