深入理解C#(第3版)-- 【C#2】第5章 进入快速通道的委托(学习笔记)

5.1  向笨拙的委托语法说拜拜

代码清单5-1  订阅的3个按钮事件

static void LogPlainEvent(object sender, EventArgs e)
{
    Console.WriteLine("LogPlain");
}

static void LogKeyEvent(object sender, KeyPressEventArgs e)
{
    Console.WriteLine("LogKey");
}

static void LogMouseEvent(object sender, MouseEventArgs e)
{
    Console.WriteLine("LogMouse");
}

...
Button button = new Button();
button.Text = "Click me";
button.Click += new EventHandler(LogPlainEvent);
button.KeyPress += new KeyPressEventHandler(LogKeyEvent);
button.MouseClick += new MouseEventHandler(LogMouseEvent);
Form form = new Form();
form.AutoSize = true;
form.Controls.Add(button);
Application.Run(form);
View Code

5.2  方法组转换

例如,在代码清单5-1中,需要创建一个KeyPressEventHandler时,会使用如下表达式:

new KeyPressEventHandler(LogKeyEvent)

C# 2支持从方法组到一个兼容委托类型的隐式转换。方法组(method group)其实就是一个方法名,它可以选择添加一个目标——换言之,和在C# 1 中创建委托实例使用的表达式完全相同。(事实上,表达式当时就已经叫做“方法组”,只是那时还不支持转换。)如果方法是泛型的,方法组也可以指定类型实参。

新的隐式转换允许我们将事件订阅转换成:

button.KeyPress += LogKeyEvent;

首先研究一下例子中出现的表达式LogKeyEvent 和MyMethod 。它们之所以被划分为方法组,是因为由于重载,可能不止一个方法适用。隐式转换会将一个方法组转换为具有兼容签名的任意委托类型。

5.3  协变性和逆变性

在静态类型的情况下,如果能调用一个方法,而且在能调用一个特定委托类型的实例并使用其返回值的任何地方都能使用该方法的返回值,就可以用该方法来创建该委托类型的一个实例。

5.3.1  委托参数的逆变性

代码清单5-1中涉及的3个委托类型的签名如下:

public delegate void EventHandler(object sender, EventArgs e)
public delegate void KeyPressEventHandler(object sender, KeyPressEventArgs e)
public delegate void MouseEventHandler(object sender, MouseEventArgs e)

如果有方法要获取一个EventArgs参数,那么始终都可以在调用它时改为传递一个KeyPressEventArgs实参。所以,用签名与EventHandler相同的方法来创建KeyPressEvent Handler的实例是完全合乎情理的,而那正是C# 2 所做的。这是参数类型逆变性的一个例子。

代码清单5-2  演示方法组转换和委托逆变性

static void LogPlainEvent(object sender, EventArgs e)  //处理所有事件 
{
    Console.WriteLine("An event occurred");
}

...
Button button = new Button();
button.Text = "Click me";
button.Click += LogPlainEvent; //使用方法组转换
button.KeyPress += LogPlainEvent; //使用转换和逆变性
button.MouseClick += LogPlainEvent; //使用转换和逆变性
Form form = new Form();
form.AutoSize = true;
form.Controls.Add(button);
Application.Run(form);

根据约定,事件处理方法的签名应包含两个参数。第1个参数是object类型,代表事件的来源;第2个参数则负责携带与事件有关的任何额外信息,它的类型派生自EventArgs。

5.3.2  委托返回类型的协变性

代码清单5-3  演示委托返回类型的协变性

delegate Stream StreamFactory(); //声明返回Stream 的委托类型

static MemoryStream GenerateSampleData() //声明返回MemoryStream的方法 
{
    byte[] buffer = new byte[16];
    for (int i = 0; i < buffer.Length; i++)
    {
        buffer[i] = (byte) i;
    }
    return new MemoryStream(buffer);
}

...
StreamFactory factory = GenerateSampleData; //利用协变性来转换方法组

using (Stream stream = factory()) //调用委托以获得Stream
{
    int data;
    while((data = stream.ReadByte()) != -1)
    {
        Console.WriteLine(data);
    }
}

5.3.3  不兼容的风险

假设一个派生类重载了某个基类中声明的方法,我们打算使用方法组转换创建一个委托的实例。由于C# 2 中的协变性和逆变性,一个以前只和基类方法匹配的转换,现在也和派生类方法相匹配。在这种情况下,编译器将选择派生类方法。

代码清单5-4  演示C# 1 和C# 2 之间的一处重大改变

delegate void SampleDelegate(string x);

public void CandidateAction(string x)
{
    Console.WriteLine("Snippet.CandidateAction");
}

public class Derived : Snippet
{
    public void CandidateAction(object o)
    {
        Console.WriteLine("Derived.CandidateAction");
    }
}

...
Derived x = new Derived();
SampleDelegate factory = new SampleDelegate(x.CandidateAction);
factory("test");
View Code

5.4  使用匿名方法的内联委托操作

5.4.1  从简单的开始:处理一个参数

.NET 2.0 引入了一个泛型委托类型Action<T>

代码清单5-5  将匿名方法用于Action<T>委托类型

Action<string> printReverse = delegate(string text) //使用匿名方法创建Action<string> 
{
    char[] chars = text.ToCharArray();
    Array.Reverse(chars);
    Console.WriteLine(new string(chars));
};

Action<int> printRoot = delegate(int number)
{
    Console.WriteLine(Math.Sqrt(number));
};

Action<IList<double>> printMean = delegate(IList<double> numbers)
{
    double total = 0;
    foreach (double value in numbers) //在匿名方法中使用循环
    {
        total += value;
    }
    Console.WriteLine(total / numbers.Count);
};

printReverse("Hello world"); //和调用普通方法一样调用委托
printRoot(2);
printMean(new double[] { 1.5, 2.5, 3, 4.5 });

首先是匿名方法的语法:先是delegate 关键字,再是参数(如果有的话),随后是一个代码块,定义了对委托实例的操作。

逆变性不适用于匿名方法:必须指定和委托类型完全匹配的参数类型。

*在值类型中编写匿名方法时,不能在其内部引用this。而在引用类型中则没有这个限制。

5.4.2  匿名方法的返回值

.NET 2.0 中很少有委托有返回值,Predicate<T>委托类型,Comparison<T>委托类型。

5.4.3  忽略委托参数

代码清单5-9  使用忽略了参数的匿名方法来订阅事件

Button button = new Button();
button.Text = "Click me";
button.Click += delegate { Console.WriteLine("LogPlain"); };
button.KeyPress += delegate { Console.WriteLine("LogKey"); };
button.MouseClick += delegate { Console.WriteLine("LogMouse"); };
Form form = new Form();
form.AutoSize = true;
form.Controls.Add(button);
Application.Run(form);

在C# 2 之前,必须显式地创建一个具有恰当签名的方法,但这样做并非总是值得的。但现在,可以像下面这样做:

public event EventHandler Click = delegate {};

这样一来,以后就可以直接调用Click,无须检查是否有任何处理程序订阅了该事件。

.NET 2.0 有4个线程构造函数: 

public Thread(ParameterizedThreadStart start)
public Thread(ThreadStart start)
public Thread(ParameterizedThreadStart start, int maxStackSize)
public Thread(ThreadStart start, int maxStackSize)

涉及的两个委托类型是:

public delegate void ThreadStart()
public delegate void ParameterizedThreadStart(object obj)

下面是创建一个新线程的3次尝试: 

new Thread(delegate() { Console.WriteLine("t1"); } );
new Thread(delegate(object o) { Console.WriteLine("t2"); } );
new Thread(delegate { Console.WriteLine("t3"); } );

第1 行和第2 行包含参数列表——编译器知道它不能将第1 行中的匿名方法转换成一个ParameterizedThreadStart,或者将第2 行中的匿名方法转换成一个ThreadStart 。这2 行都能成功编译,因为在每种情况下,都只有一个适用的构造函数重载版本。第3 行则会产生歧义——匿名方法可以转换成两种委托类型,所以只获取一个参数的两个构造函数重载版本都是适用的。

5.5  匿名方法中的捕获变量

5.5.1  定义闭包和不同类型的变量

闭包,基本概念是:一个函数除了能通过提供给它的参数交互之外,还能同环境进行更大程度的互动。

外部变量(outer variable)是指作用域(scope)内包括匿名方法的局部变量或参数(不包括ref 和out 参数)。在类的实例成员内部的匿名方法中,this引用也被认为是一个外部变量。

捕获的外部变量(captured outer variable)通常简称为捕获变量(captured variable ),它是在匿名方法内部使用的外部变量。重新研究一下“闭包”的定义,其中所说的“函数”是指匿名方法,而与之交互的“环境”是指由这个匿名方法捕获到的变量集。

主要强调的就是,匿名方法能使用在声明该匿名方法的方法内部定义的局部变量。

代码清单5-10   不同种类的变量和匿名方法的关系

void EnclosingMethod()
{
    int outerVariable = 5; //外部变量(未捕获的变量)
    string capturedVariable = "captured"; //被匿名方法捕获的外部变量
    
    if (DateTime.Now.Hour == 23)
    {
        int normalLocalVariable = DateTime.Now.Minute; //普通方法的局部变量
        Console.WriteLine(normalLocalVariable);
    }
    
    MethodInvoker x = delegate()
    {
        string anonLocal = "local to anonymous method"; //匿名方法的局部变量 
        Console.WriteLine(capturedVariable + anonLocal); //捕获外部变量 
    };
    
    x();
}

由易到难来解释所有这些变量:
 ormalLocalVariable 不是外部变量,因为它的作用域内没有匿名方法。它的行为和普通局部变量别无二致。
 anonLocal 也不是外部变量,它是匿名方法的局部变量,但不是EnclosingMethod的局部变量。只有委托实例被调用之后,它才会存在[于一个正在执行的栈帧(frame)中 ]。
 outerVariable 是一个外部变量,因为在它的作用域内声明了一个匿名方法。但是,匿名方法没有引用它,所以它未被捕捉。
 capturedVariable 是一个外部变量,因为在它的作用域内声明了一个匿名方法,而且由于在这个位置使用了该变量,所以它成为了一个被捕捉的变量。

5.5.2  捕获变量的行为

在匿名方法外部对变量的更改在匿名方法内部是可见的,反之亦然。

代码清单5-11   从匿名方法内外访问一个变量

string captured = "before x is created";

MethodInvoker x = delegate
{
    Console.WriteLine(captured);
    captured = "changed by x";
};

captured = "directly before x is invoked";
x();

Console.WriteLine(captured);

captured = "before second invocation";
x();

需要注意的是,创建委托实例不会导致执行。

请记住,假如只是创建一个委托实例,不会读取变量,并将它的值存储到某个地方。为了证明这一点,现在将captured 的值更改为"directly before x is invoked"。然后,我们第一次调用x 。

5.5.3  捕获变量到底有什么用处

捕获变量能简化避免专门创建一些类来存储一个委托需要处理的信息(除了作为参数传递的信息之外)。

在ParameterizedThreadStart问世以前,如果你想启动一个新(非线程池)线程,并向其提供一些信息(比如要获取的一个网页的URL )就不得不创建一个额外的
类型来容纳URL,并将ThreadStart 委托实例的操作放到那个类型中。即使对于ParameterizedThreadStart来说,我们的方法也不得不接受一个object 类型的参数,再将其强制转换为所需要的类型。

5.5.4  捕获变量的延长生存期

对于一个捕获变量,只要还有任何委托实例在引用它,它就会一直存在。

代码清单5-12   捕捉变量的生存期延长了

static MethodInvoker CreateDelegateInstance()
{
    int counter = 5;
    MethodInvoker ret = delegate
    {
        Console.WriteLine(counter);
        counter++;
    };
    ret();
    return ret;
}

...
MethodInvoker x = CreateDelegateInstance();
x();
x();

输出结果分别显示数字5 、6 和7 。第1 行输出是在CreateDelegateInstance 内部调用委托实例的结果。这证明了counter的值在那个时候是可用
的。但是,当方法返回之后呢?我们一般会认为counter在栈上,所以只要与CreateDelegate-Instance 对应的栈帧被销毁,counter也会随之消失……但令人惊讶的是,以后调用返回的委托实例时,使用的似乎还是那个counter。
秘密在于前面那个假设,counter真的是在栈上吗?答案是否定的。事实上,编译器创建了一个额外的类来容纳变量。CreateDelegateInstance方法拥有对该类的一个实例的引用,所以它能使用counter。另外,委托也有对该实例的一个引用,这个实例和其他实例一样都在堆上。除非委托准备好被垃圾回收,否则那个实例是不会被回收的。

5.5.5  局部变量实例化

int single;
for (int i = 0; i < 10; i++)
{
    single = 5;
    Console.WriteLine(single + i);
}
for (int i = 0; i < 10; i++)
{
    int multiple = 5;
    Console.WriteLine(multiple + i);
}

在过去,这样的两段代码在语义上完全一致,所以通常会编译成相同的IL 。假如不涉及任何匿名方法,现在仍将这样处理。局部变量所需的全部空间都在方法开始时在栈上分配,所以不会产生每次循环迭代都“重新声明”变量的开销。但是,采用我们的新术语,single变量只实例化一次,而multiple 变量将实例化10 次——就像有10个局部变量,全部都叫做multiple ,它们一个接一个地创建。
当一个变量被捕获时,捕捉的是变量的“实例”。如果在循环内捕捉multiple ,第一次循环迭代时捕获的变量与第二次循环时捕获的变量是不同的,以此类推。代码清单5-13 演示了由此产生的影响。

代码清单5-13   使用多个委托来捕捉多个变量实例

List<MethodInvoker> list = new List<MethodInvoker>();

for (int index = 0; index < 5; index++)
{
    int counter = index * 10; //实例化counter
    list.Add(delegate
    {
        Console.WriteLine(counter); //打印并递增捕获的变量
        counter++;
    });
}

foreach (MethodInvoker t in list)
{
    t(); //执行全部5 个委托实例
}

//第1个委托多执行3次 
list[0]();
list[0]();
list[0]();

list[1]();//第2个委托多执行1次

调用委托时,会先打印counter的值,再对它进行递增。现在,由于counter变量是在循环内部声明的,所以每次循环迭代,它都会被实例化 。这样一来,每个委托捕捉到的都是一个不同的变量。所以,依次调用每个实例,就可以看到每次赋给counter的不同的初始值:0 、10 、20 、30 、40。

为了加深你的理解,当返回第1个委托实例,再多执行它3次时,它会从那个实例的counter变量停止的地方继续,所以会输出1,2,3。最后,我们多执行一次第2个委托实例,这将从那个实例的counter变量停止的地方继续,所以会输出11 。

foreach循环具有同样的行为:由循环的初始部分声明的变量只被实例化一次。这很容易弄错!如果你想捕获循环变量在一次特定的循环迭代中的值,必须在循环内部引入另一个变量,并将循环变量的值复制给它,再捕捉那个新变量。

5.5.6  共享和非共享的变量混合使用

代码清单5-14   捕捉不同作用域中的变量。(并不推荐你写这样的代码!)

MethodInvoker[] delegates = new MethodInvoker[2];
int outside = 0; //实例化变量一次

for (int i = 0; i < 2; i++)
{
    int inside = 0; //实例化变量多次
    delegates[i] = delegate //使用匿名方法捕获变量
    {
        Console.WriteLine ("({0},{1})", outside, inside);
        outside++;
        inside++;
    };
}

MethodInvoker first = delegates[0];
MethodInvoker second = delegates[1];

first();
first();
first();
second();
second();

5.5.7  捕获变量的使用规则和小结

使用捕获变量时,请参照以下规则:
 如果用或不用捕获变量时的代码同样简单,那就不要用。
 捕获由for 或foreach语句声明的变量之前,思考你的委托是否需要在循环迭代结束之后延续,以及是否想让它看到那个变量的后续值。如果不是,就在循环内另建一个变量,用来复制你想要的值。(在C# 5 中,你不必担心foreach语句,但仍需小心for 语句。)
 如果创建多个委托实例(不管是在循环内,还是显式地创建),而且捕获了变量,思考一下是否希望它们捕捉同一个变量。
 如果捕捉的变量不会发生改变(不管是在匿名方法中,还是在包围着匿名方法的外层方法主体中),就不需要有这么多担心。
 如果你创建的委托实例永远不从方法中“逃脱”,换言之,它们永远不会存储到别的地方,不会返回,也不会用于启动线程——那么事情就会简单得多。
 从垃圾回收的角度,思考任何捕获变量被延长的生存期。这方面的问题一般都不大,但假如捕获的对象会产生昂贵的内存开销,问题就会凸现出来。

重要知识点:

 捕获的是变量,而不是创建委托实例时它的值。
 捕获的变量的生存期被延长了,至少和捕捉它的委托一样长。

 多个委托可以捕获同一个变量……
 ……但在循环内部,同一个变量声明实际上会引用不同的变量“实例”。
 在for 循环的声明中创建的变量仅在循环持续期间有效——不会在每次循环迭代时都实例化。这一情况对于C# 5 之前的foreach语句也适用。
 必要时创建额外的类型来保存捕获变量。

posted @ 2019-10-14 11:31  FH1004322  阅读(171)  评论(0)    收藏  举报