65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的方法是头等对象

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.09/5 (5投票s)

2013 年 3 月 31 日

CPOL

13分钟阅读

viewsIcon

19608

downloadIcon

116

在 C# 中,委托可以通过闭包(closure)来访问外部变量,就像头等对象一样。

介绍 

随着 C# 的发展,如今的代码大量依赖于 lambda 表达式。回调函数与 `Action` 和 `Func<T>` 等委托非常普遍。我们毫不犹豫地访问 lambda 表达式范围之外的变量。这非常方便,并提供了一种构建编程模型的好方法,否则使用传统的面向对象编程将是一个挑战。在这里,我试图讨论称为闭包的构造以及它留下的内存占用。了解这些基本概念无疑有助于编写更好的代码。我的想法是讨论核心概念,而不过多深入内部细节。

在我们深入探讨之前,让我们先回顾一下基础知识。

什么是类?它是定义对象的模板。我在维基百科上找到了这个定义:

在面向对象编程中,**类**是一种用于创建自身实例的构造——这些实例被称为类

实例、类对象、实例对象或简称为对象。类定义了构成成员,这些成员使得其实例能够拥有状态和行为。数据字段成员(成员变量或实例变量)使类实例能够维护状态。其他类型的成员,尤其是方法,使得类实例能够具有行为。类定义了其实例的类型。

如果您是 **C#** 开发者,那么这里没有什么新东西——我们都知道——然后还有其他与类相关的内容;例如:**静态**类、**密封**类、**嵌套**类、**泛型**等等。然后它提供了面向对象的行为;例如**抽象**、**重载**和**封装**。

结构化编程和面向对象编程之间的关键区别在于引入了**封装**、**关联**、**聚合**和**组合**等概念。暂时我们先不讨论抽象和多态的概念,尽管它们同样重要,甚至更重要。

最基本的意义上,**OOPS** —— 告诉我们将数据和功能关联在一起,并限制对数据的非必要暴露。类通过在类型上设置私有修饰符来实现这一点,只有类中定义的方法才能直接操作这些类型。此修饰符也适用于行为。对外部世界不相关的功能/行为将从外部不可见。它有助于维护良好的设计。

对象在任何时候包含的数据称为状态。状态可以在运行时随时更改。存储此状态信息需要在堆或栈上占用一些内存空间。当对象不再有用或无法从代码访问时,它就应该被销毁,其状态占用的内存应被释放,以便提供给其他需要它的对象。

在我们继续之前,让我们讨论一下内存释放方面的内容。

从 **C** 语言开始,编程构造就是**块结构**的。在此之前,我们使用过像 **jump** 和 **goto** 这样的语句——这些是随机调用。您可以从任何地方跳转到任何定义的标签——因此执行流程不可预测,并且非常难以理解。块的引入是高级语言中最有用的特性之一。在 **C** 系列以及大多数其他语言中——块由花括号 **{ }** 定义。*(随着 C# 的发展,块经常被推断出来)*。此块定义了其中任何内容的范围。在 **C** 中,块可以存在于函数级别,并在其中存在于循环、分支以及其他任何内容中。块内块*(嵌套块)*都可以。函数是块可以存在的最高级别。在它之外定义的任何内容都将成为全局的。通过包含头文件,在文件级别定义的所有变量都可以全局可用。在当前场景中,我们可以将这些视为静态。所以当时只有静态的东西——实例的概念不存在。

为了将实例的概念(或 **OOPS**)引入图中,只需要一件事。那就是允许**函数之外的块**。这以类的形式出现。类将有一个名称,该名称定义一个范围——在其内部定义的任何内容都将仅限于该类。类不像函数那样是执行单元。它是一个具有某些状态的实体,提供某些行为/服务/功能。这个新概念称为 OOPS。然后它提供了额外的功能——例如继承、封装、抽象、多态、静态等。但是,在函数限制之外引入块是结构化编程和面向对象编程之间最根本的区别之一。系统定义的类型已经存在,如 int、char、float。沿着这条线,现在我们也可以自由定义自己的类型了。

我有跑题的坏习惯!让我们回到关于资源释放(特别是内存)背后的概念。

如果一个变量/对象在函数内部定义,那么直到有人调用该函数,该变量才会存在。当调用函数时,变量会在内存中的某个位置创建,然后使用,当函数完成其工作并结束时,该变量就没用了。没有代码可以访问它——它超出了范围。所以这时,该变量占用的内存应该被释放。任何在任何范围内定义的变量也是如此。一旦范围消失,所占用的资源就应该被释放。

**C++** 的析构函数就是这样工作的。当一个对象超出范围时,析构函数会立即被调用。出于某些原因(*执行一段时间后内存点分散是主要原因*),这不是最好的方法,所以后来(*从 Java 开始*)清理过程开始遵循一种称为垃圾回收的新概念。它会定期清理,而不是立即清理,并且对于大型系统,这有助于提高性能并保持更清晰的内存占用。

忘记清理的时机——基本的事情是,当一个变量/对象超出范围时,它应该立即或在一段时间后(*某个固定时间*)释放所占用的资源。

为了实现这一点——框架应该知道某个东西现在已经超出了范围。对于内部块/函数来说,这是最容易确定的,对于类来说也是可以的——但在**闭包**的情况下,情况会变得有点复杂。我们将看到为什么它会复杂,但在此之前我们必须先了解闭包。

什么是闭包?

让我们看看 Wikipedia 是如何定义的。

计算机科学中,**闭包**(也称为*词法闭包*或*函数闭包*)是函数或对函数的引用,连同*引用环境*——一个存储对该*函数*的*非局部变量*(也称为*自由变量*)的*引用的*表。一个闭包——与一个普通的*函数指针*不同——允许函数在其*词法范围*之外调用时也能访问这些非局部变量。

闭包的概念是在 20 世纪 60 年代开发的,并于 1975 年首次作为*Scheme*编程语言的一项语言特性得到完全实现,以支持*词法作用域*的*头等函数*。闭包的明确使用与*函数式编程语言*(如*Lisp*和*ML*)相关,因为传统的命令式*语言*(如*Algol*、*C*和*Pascal*)不支持将嵌套*函数*作为*高阶函数*的结果返回,因此也不需要支持闭包。许多现代*垃圾回收*的命令式语言都支持闭包,例如*Smalltalk*(第一个这样做*-的*面向对象语言)和*C#*。*Java*对闭包的支持计划在 Java 8 中实现。

所以——这不是什么新鲜事,也不是 OOPS 的概念,它在面向对象编程之前就存在了。但是它有一个非常好的特性,可以将函数视为一个具有某些状态的实体;这现在得到了**C#**等面向对象语言的支持。基本意义上,它意味着将函数视为一个对象,并期望它记住创建它的环境。函数被作为对象传递,它也包含一些状态。

但 C# 是一个完全面向对象的语言,所以它需要某种方式将这些也视为对象。让我们看一个例子。

public class BasicClosure
{
  private IntOne one = new IntOne() { name = "IntOne+one", value = 30 };
  private IntOne two = new IntOne() { name = "IntOne+two", value = 30 };
  void IWillBeExposed()
  {
    one.value += 10;
  }
  public Action AddValue()
  {
    return IWillBeExposed;
  }
  ~BasicClosure()
  {
    LogDetail.DebugLogs.Add(new LogDetail() { Name = "BasicClosure", 
      Method = "~BasicClosure", Variable = "one", Value = one.value.ToString() });
  }
}
 
public class IntOne
{
    public int value = 0;
    public string name = "";


    ~IntOne()
    {
        LogDetail.DebugLogs.Add(new LogDetail() { Name = name, Method = 
          "~IntOne", Variable = "value", Value = value.ToString() });
    }
}




public class LogDetail
{
    public string Name = "";
    public string Method = "";
    public string Variable = "";
    public string Value = "";


    public override string ToString()
    {
        return string.Format(
           "Name : {0}, Method : {1}, Variable : {2}, Value : {3}", Name, Method, Variable, Value);
    }




    public static List<logdetail> DebugLogs = new List<logdetail>();
}
 
public partial class TestBasicClosure : Form
{
    
    public TestBasicClosure()
    {
        InitializeComponent();
    }


    Action addValue = null;
    
    private void btnStart_Click(object sender, EventArgs e)
    {
        BasicClosure fbc = new BasicClosure();
        addValue = fbc.AddValue();
    }

    private void btnCall_Click(object sender, EventArgs e)
    {
        addValue();
    }

    private void btnLog_Click(object sender, EventArgs e)
    {
        rtbLog.Clear();
        rtbLog.Lines = LogDetail.DebugLogs.Select(dl => dl.ToString()).ToArray();
        rtbLog.Refresh();
    }

    private void btnGC_Click(object sender, EventArgs e)
    {
        GC.Collect();
    }

    private void btnClr_Click(object sender, EventArgs e)
    {
        addValue = null;
    }
}

我们有一个 `BasicClosure` 类,它有一个私有的 void 方法 `IWillBeExposed`,该方法符合 Action 委托的签名。还有另一个方法 `AddValue`,它返回私有方法的引用作为 Action。

现在,在 `TestBasicClosure` 类的 `Start_Click` 方法中,我们创建了一个 `BasicClosure` 的对象——调用 `AddValue` 并将 Action 存储在一个实例变量 `addValue` 中。`fbc` 对象在 `Start_Click` 结束时立即超出范围。

这是一个糟糕的设计——函数不应该这样暴露在类之外。在这种情况下,由于函数引用,对象 `fbc` 的生命周期会延长,并且只要函数引用存在,它就不会释放内存。在测试类中,我们还有另一个方法请求**垃圾回收器**尽快开始清理。即使调用此方法后,您也会看到——对象 `fbc` 的终结器没有被调用。`Log_Click` 显示所有已清理对象的日志*(我创建这个是为了方便跟踪)*。

在上面的代码中,`fbc` 对象引用本身并不存在——这意味着我们无法对该对象执行任何操作——除了调用暴露的函数*(而且这个函数也从来不是通过对象调用的)*。该函数使用一个实例变量,名为 `one`。还有一个变量 `two` 没有被暴露的函数访问——所以逻辑上它应该被清理。`one` 的生命周期可能会延长,因为它仍然是一个可达的引用,但由于 `two` 不可达,所以清理它并没有什么坏处。在真正的函数式语言中,应该如此——但由于 **C#** 是一种面向对象语言,所以不会这样。它只知道一件事——类的一个成员仍然可达,所以对象不能被清理。

暴露的函数在这里基本上起着**闭包**的作用。函数作为一个实体起作用,并延长了它被创建的上下文的生命周期。直到此时,这并不是真正有用的;即使该方法不引用任何实例变量,对象的生命周期也会延长——只要任何成员函数引用超出范围。

对于静态方法,不会发生这种情况,因为它们不与实例绑定。

现在让我们看下一个例子:

public class TestClosure
{
    // Create a instance of CLOSURE and call this method to test the behavior
    public void Test() 
    { 
        // this call will get three Action delegates
        // these delegates are aware of environment in which they were created.
        var shout = Shout(new string[] {"John", "Bill", "Danish"} );

        shout[0].Invoke(); // it shows John as name would get tied to the delegate
        shout[2].Invoke();
        shout[1].Invoke();
    }

    List<action> Shout(string[] names)
    {
        List<action> actions = new List<action>();

        foreach (string currName in names)
        {
            //this assignment is important
            string name = currName;
            // if delegate forms closure on currName then as that
            // is shared by all the delegates and that is changing so all
            // delegate will end-up having same closure.
            actions.Add(
                            // the name here is outer variable with which
                            // the Action delegate is forming a closure
                            () => MessageBox.Show(name)
                            //() => MessageBox.Show(currName)
                            // un-comment this and comment the line above to see the difference
                       );
            // CLOSURE can be formed by anonymous delegates also.
            //actions.Add(delegate() 
            //            {
            //                MessageBox.Show(name);
            //            });
        }
        return actions;
    }
}

在此示例中,`Shout` 方法根据 `names` 参数中提供的字符串数量动态创建一个 Action 委托列表。Action 委托体内的 `MessageBox.Show` 调用——正在访问循环内部创建的外部变量 `name`。

当您运行 `Test` 方法时,它将逐个在消息框中显示所有 `names`。

这里要注意的一件事是声明在循环范围内的 `name` 变量。所以实际上,访问 `name` 的 Action 委托每次都会不同——换句话说,每个 `action` 都有它自己的 `outer variable` 副本。您可以看到的区别是,如果您显示 `name` 而不是 `currName`。这个变量不在循环的范围内;所以它不会每次循环都创建。所以如果我们显示 `currName`——那么对于每个 action,`currName` 的最后一个值将被闪现。

当一个方法或委托对某些外部变量形成闭包时——它不会复制值。它只是将该变量的引用与之关联。在调用此类委托时,将需要关联的变量,并且当时包含的值将被采用。这就是为什么在 `currName` 的情况下,它将始终是最后一个可用的值,因为所有 action 都在循环结束后很久才被调用。

这关于作用域,我们应该如何将外部变量与**lambda 表达式/匿名委托**等关联起来。现在,让我们看看像 **C#** 这样的面向对象语言是如何实现的,以及内存管理是如何与所有这些结合的?方法是如何被视为头等对象的?这里是另一个示例代码

public class FCO
{
    public string name = "FCO";
    IntOne y = new IntOne() { name = "Y", value = 20 };

    public void TestLifeTime(out Func<int> func1, out Func<int> func2) {

        IntOne i = new IntOne() { name = "I", value = 10 };
        IntOne j = new IntOne() { name = "J", value = y.value };
        IntOne k = new IntOne() { name = "K", value = y.value }; // not part of any closure

        // closure on i
        Func<int> add10 = () => 
        { 
            i.value += 10; // outer variable
            return i.value; 
        };
        // closure on i and j
        Func<int> addFive = () => 
        { 
            j.value = i.value + 5; // outer variable i and j
            return j.value; 
        };

        func1 = add10;
        func2 = addFive;

        k.value++; // simple operation
    }

    ~FCO(){
        y = new IntOne() { name = "new Y" };
        LogDetail.DebugLogs.Add(new LogDetail() {Name = name,  Method = "~FCO", Variable = "y", Value = y.value.ToString() }); 
    }
}

这是一个用于测试此类的 Windows 窗体

public partial class TestFCO : Form
{
    public TestFCO()
    {
        InitializeComponent();
    }

    Func<int> f1 = null;
    Func<int> f2 = null;


    private void btnStart_Click(object sender, EventArgs e)
    {
        FCO fco = new FCO();
        fco.TestLifeTime(out f1, out f2);
    }

    private void btnAddTen_Click(object sender, EventArgs e)
    {
        if (f1 != null)
        {
            lblOne.Text = f1().ToString();
        }
    }
 
    private void btnAddFive_Click(object sender, EventArgs e)
    {
        if (f2 != null)
        {
            lblTwo.Text = f2().ToString();
        }
    }

    private void btnGC_Click(object sender, EventArgs e)
    {
        GC.Collect();
    }

    private void btnLog_Click(object sender, EventArgs e)
    {
        rtbLog.Clear();
        rtbLog.Lines = LogDetail.DebugLogs.Select(dl => dl.ToString()).ToArray();
        rtbLog.Refresh();
    }

    private void btnClrTen_Click(object sender, EventArgs e)
    {
        f1 = null;
    }

    private void btnClrFive_Click(object sender, EventArgs e)
    {
        f2 = null;
    }
} 

我们在启动时创建一个 `FCO` 实例——然后我们得到两个作为 out 参数提供的 Action 委托。`start` 方法结束,因此局部创建的**对象超出范围**。与第一个示例不同,我们没有暴露 `FCO` 的任何实例成员,因此终结器没有理由不触发。由于垃圾回收器可能需要一些时间,因此我们请求立即清理,使用 `GC.Collect()`。我们可以看到日志,如果某些对象已释放。以下是我调用 `start` 然后 `collect` 然后 `getLog` 的结果。

名称:FCO,方法:~FCO,变量:y,值:0
名称:Y,方法:~IntOne,变量:value,值:20
名称:K,方法:~IntOne,变量:value,值:21

如果我再次调用 `collect` 然后 `getLog`,我将获得另一行日志。

名称:新的 Y,方法:~IntOne,变量:value,值:0

那么这里发生了什么——`FCO` 终结器被调用。`Y` 是一个实例成员,没有人对其进行闭包,因此它的终结器也被调用。在 `FCO` 终结器中,创建了一个新对象并将其保留在 `Y` 中。`K` 是函数 `TestLifeTime` 的局部变量,没有人对其进行闭包*(取决于未来)*,因此它的终结器也被调用。

新创建的 `Y` 没有引用,但它在清理过程中创建,因此它将**在第一次 GC 循环中存活**,但在下一次 `GC.Collect()` 调用之后,它也会被收集,这就是为什么我们在第二次调用 `GC.Collect()` 后会得到额外的日志。

现在我们创建了两个闭包。对象已经死了,但闭包作为 `f1` 和 `f2` 成员变量在 `TestFCO` 中存活。这里 `I` 和 `J` 的生命周期被延长,因为

`f1` 闭合了 `I`,而

`f2` 闭合了 `I` 和 `J` 两者。

`I` 和 `J` 是它们被定义的函数的局部变量——函数执行已经结束了,那么 `I` 和 `J` 怎么还能存活?它们会与哪个对象绑定?因为除了执行上下文的局部变量之外——状态必须与一个对象相关联,否则 `GC` 会清理它。好吧,如果我们清空 `TestFCO` 中的 `f2` 引用会怎么样?逻辑上 `J` 应该被清理,因为 `f1` 没有闭合 `J`,它只需要 `I`,所以持有 `J` 没有什么关联。在我的演示项目中,我在 `Clear 5` 按钮上做了这个——但令我惊讶的是——`J` 没有被收集。但当我清空 `f1` 时,`I` 和 `J` 都被收集了。

这个问题将会一直存在,这是因为面向对象的构造。尽管如此,它并没有构成很大的威胁。

当你创建一个闭包时——在编译时会动态创建嵌套类,用于所有被闭包函数/委托访问的变量的作用域。但编译器并非愚蠢。在嵌套类中,只有那些被闭包访问的成员才会参与。请参阅下图,从 **IL 反汇编程序**中查看。`c_displayClass5` 是用于辅助闭包的动态生成的类型。进一步我们可以看到 `I` 和 `J` 都是这个类的成员,但 `k` 不是。这个类的实例与委托相关联,因此这个实例将一直存在,直到委托的任何引用存在。在这里,如果这个类型被 `f1` 和 `f2` 这两个委托共享,那么即使 `f2` 死亡且 `J` 不可达——`J` 仍然存在,因为它属于与 `I` 相同的对象。这就是在面向对象环境中如何支持函数式构造。它可能会延长某些不需要的变量的生命周期*(这里是 `J` 的情况)*,但仍然有用。通过仔细设计并牢记这些概念,几乎可以避免这个问题,并且在大多数情况下,这种影响是微不足道的。

这就是为什么我们说**C# 中的方法被视为头等对象**。它们表现得像一个简单的对象——事实上,编译器会创建简单的对象来保存它们所需的任何状态,以防它们形成对某些外部变量的闭包。这些对象很简单,不会涉及抽象和多态的程度,因为在这些情况下也不需要。

至此,我将结束这篇文章。希望我没有遗漏任何重要部分。谢谢。

© . All rights reserved.