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

C# 专业技术 - 讲义第四部分(共四部分)

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (84投票s)

2016 年 4 月 21 日

CPOL

40分钟阅读

viewsIcon

97679

最后一部分讨论了特性、迭代器和一些更高级的主题。

这些文章代表讲义,最初是作为 Tech.Pro 上的教程提供的。

目录

  1. 引言
  2. 事件控制增强
  3. 运算符重载
  4. Yield 语句
  5. 迭代器
  6. 理解协变与逆变
  7. 有效使用特性
  8. 优雅的绑定
  9. 不安全代码
  10. 原生代码与托管代码之间的通信
  11. 高效 C#
  12. Outlook(展望)
  13. Other Articles in This Series(本系列其他文章)
  14. 参考文献
  15. 历史

本教程旨在对 C# 编程进行简要而深入的介绍。理解本教程的前提是您已掌握编程、C 语言以及一些基础数学知识。掌握 C++ 或 Java 的一些基础知识将会有所帮助,但并非必需。

引言

这是 C# 系列教程的第四部分(很可能也是最后一部分)。在本部分中,我们将探讨该语言的一些更高级的技术和特性。我们将了解如何控制编译器在添加或移除事件处理程序时的行为,如何引入自定义的运算符定义或获取有关成员名称的信息。我们还将看到,使用 C# 构建迭代器实际上非常容易。

此外,我们将巩固关于如何编写高效 C# 代码、垃圾回收器和特性的知识。最后,我们还将讨论如何通过 `unsafe` 上下文和本机互操作性使用 C# 与原生程序和库进行通信。本教程将为经验丰富的 C# 开发人员提供一些有用的技巧。

有关更多阅读,最后将提供参考文献列表。参考文献将对本教程中讨论的某些主题进行更深入的探讨。

事件控制增强

在上一篇教程中,我们讨论了 .NET 标准事件模式和 `event` 关键字。我们注意到,原则上,`event` 关键字保护了一个简单的 `public delegate` 实例,防止其被直接访问。相反,我们只能从外部添加(`+=`)或移除(`-=`)处理程序。

我们已经解释过,这样做的原因是因为编译器会在添加或移除事件处理程序时扩展我们的类定义,并添加两个额外的方法。这些方法会以线程安全的方式执行操作,例如调用 `Delegate` 类的静态 `Combine` 方法。

C# 还允许我们定义这些方法的作用。这可能非常有益,因为它允许我们创建弱事件处理程序和其他更复杂的模式。这还允许我们控制哪些处理程序可以为我们的事件注册(或注销)。基本语法非常接近于属性的 `get` 和 `set` 块。让我们来看一个实际的例子:

class MyClass
{
    public event EventHandler EventFired
    {
        add
        {
            Console.WriteLine("Tried to register a handler.");
            //value is the name of the delegate instance
        }
        remove
        {
            Console.WriteLine("Tried to unregister a handler.");
            //value is the name of the delegate instance
        }
    }
}

我们现在可以使用另一个(`private` 且未标记为 `event` 的)`delegate` 实例(在本例中为 `EventHandler`),或者我们可以执行其他(可能更特殊的)操作。我们可以做的一件事是使用单个处理程序而不是多个处理程序。

class MyClass
{
    EventHandler singleHandler;

    public event EventHandler EventFired
    {
        add
        {
            singleHandler = value;
        }
        remove
        {
            if (singleHandler == value)
                singleHandler = null;
        }
    }
}

然而,这种模式也只在一些特殊场景下有用。所以,让我们继续看看编写我们自己的 `add` 和 `remove` 块可能最常见的用法。

Hashtable events = new Hashtable();

public event EventHandler Completed 
{
    add 
    {
        events["Completed"] = (EventHandler)events["Completed"] + value;
    }
    remove
    {
        events["Completed"] = (EventHandler)events["Completed"] - value; 
    }
}

在这里,我们使用加号和减号运算符,它们由委托重载以调用 `Combine` 或 `Remove`。

运算符重载

在本系列教程中,我们还没有讨论过为我们自己的对象实现运算符的可能性。C# 允许我们重载隐式转换运算符、显式转换运算符以及一系列算术/比较运算符(`+`, `-`, `!`, `~`, `++`, `--`, `true`, `false`, `+`, `-`, `*`, `/`, `%`, `&`, `|`, `^`, `<<`, `>>`, `==`, `!=`, `<`, `>`, `<=`, `>=`)。其他运算符要么无法重载(如赋值运算符),要么会自动重载(如,如果我们重载了 `+`,那么 `+=` 也会被重载)。

无法重载索引运算符 `[]`,但我们可以创建自己的索引器。

class MyClass
{
    public int this[int index]
    {
        get { return index + 1; }
    }
}

//Using it:
var myc = new MyClass();
int value = myc[3]; //Will be 4

索引器类似于属性,但区别在于它们有一个特殊名称(`this`)并且需要指定一个或多个参数(在本例中,我们定义了一个名为 `index` 的整数参数)。这样,我们可以轻松创建自己的多维索引器。

class Matrix
{
    double[][] elements;

    /* some code */

    public double this[int row, int column]
    {
        get 
        {
            if (row < 0 || column < 0)
                throw new IndexOutOfRangeException();
            else if (elements.Length >= row)
                return 0.0;
            else if (elements[row].Length >= column)
                return 0.0;

            return elements[row][column];
        }
        set
        {
            if (row < 0 || column < 0 || elements.Length >= row || 
                elements[row].Length >= column)
                throw new IndexOutOfRangeException();
            
            elements[row][column] = value;
        }
    }
}

我们可以拥有任意数量的此类索引器,只要每个索引器都有唯一的签名(不同的参数数量或类型)。

为了演示运算符重载如何与前面提到的运算符一起工作,我们将使用一个示例来创建一个简单的结构 `Complex`(这个 `struct` 将代表一个双精度复数)。

public struct Complex
{
    double re;
    double im;

    public Complex(double real, double imaginary)
    {
        re = real;
        im = imaginary;
    }

    public double Re
    {
        get { return re; }
        set { re = value; }
    }

    public double Im
    {
        get { return im; }
        set { im = value; }
    }
}

我们首先可能想实现的是从 `double` 到 `Complex` 的隐式转换。`double` 就像一个实数值,即,这种转换应该给我们一个虚部为 0 的复数值。

public static implicit operator Complex(double real)
{
    return new Complex(real, 0.0);
}

我们会看到,所有运算符定义都需要 `operator` 关键字。对于重载隐式转换,还需要 `implicit` 关键字。无需多言,每个运算符重载都必须是 `static`。

还应该有一个从 `Complex` 到 `double` 的显式转换。这个显式转换用于使用复数的绝对值。

public static explicit operator double(Complex c)
{
    return Math.Sqrt(c.re * c.re + c.im * c.im);
}

也许我们想使用我们的复数对象来定义 `true`(用于 `if (instance) ...`)或 `false`(将与重写 `&` 一起使用)。让我们看看这如何实现。

public static bool operator true(Complex c) 
{
    return Math.Abs(c.re) > double.Epsilon || Math.Abs(c.im) > double.Epsilon;
}

public static bool operator false(Complex c) 
{
    return Math.Abs(c.re) <= double.Epsilon && Math.Abs(c.im) <= double.Epsilon;
}

另一方面,如果我们实现了 `true` 或 `false` 的表示,我们也应该实现(至少显式)到 `bool` 的转换。

public static explicit operator bool(Complex c)
{
    return new Boolean(Math.Abs(c.re) > double.Epsilon || Math.Abs(c.im) > double.Epsilon);
}

最后,我们应该考虑实现(必需的)算术运算符。

public static Complex operator +(Complex c1, Complex c2) 
{
    return new Complex(c1.re + c2.re, c1.im + c2.im);
}

public static Complex operator -(Complex c1, Complex c2) 
{
    return new Complex(c1.re - c2.re, c1.im - c2.im);
}

public static Complex operator *(Complex c1, Complex c2) 
{
    return new Complex(c1.re * c2.re - c1.im * c2.im, c1.re * c2.im + c1.im * c2.re);
}

public static Complex operator /(Complex c1, Complex c2) 
{
    double nrm = Math.Sqrt(c2.re * c2.re + c2.im * c2.im);
    return new Complex((c1.re * c2.re + c1.im * c2.im) / nrm, 
                       (c2.re * c1.im - c2.im * c1.re) / nrm);
}

最后,我们可能还想实现一些比较运算符。这遵循与上面相同的模式,但是,我们现在指定返回类型为 `Boolean` 以便使用。

public static bool operator ==(Complex c1, Complex c2)
{
    return Math.Abs(c1.re - c2.re) <= double.Epsilon && 
                Math.Abs(c1.im - c2.im) <= double.Epsilon;
}

public static bool operator !=(Complex c1, Complex c2)
{
    return !(c1 == c2);
}

如果我们实现了这些比较运算符,C# 编译器会鼓励我们(通过警告)也重写 `GetHashCode` 和 `Equals` 方法。

public override int GetHashCode()
{
    return base.GetHashCode();
}

public override bool Equals(object o)
{
    if (o is Complex)
        return this == (Complex)o;
    
    return false;
}

此时可能会出现一个问题:涉及,例如,`double` 和 `Complex` 的运算符怎么办?我们不需要类似以下的内容吗?

public static Complex operator +(Complex c, Double x) 
{
    return new Complex(c.re + x, c.im);
}

确切的答案是:是也不是。一般来说,我们可能需要类似这样的东西,但那时我们也需要定义以下运算符重载:

public static Complex operator +(Double x, Complex c) 
{
    return new Complex(c.re + x, c.im);
}

然而,在我们的例子中,我们指定了从 `Double` 到 `Complex` 的隐式 (!) 转换。这意味着,如果一个 `Complex` 类型在需要 `Double` 类型对象的地方出现,会自动执行转换(编译器会插入所需的指令)。当然,有时显式包含此类运算符重载可能是有益的(在性能或逻辑方面)。

最后,我们可以如下使用我们自己的类型:

Complex c1 = 2.0;       //Implicit cast in action
Complex c2 = new Complex(0.0, 4.0);
Complex c3 = c1 * c2;
Complex c4 = c1 + c2;
Complex c5 = c1 / c2;
double x = (double)c5;  //Explicit cast in action

那么,这一部分的关键经验是什么?

  • C# 允许我们重载广泛的运算符,但不如 C++ 那样多(并且不像赋值运算符那样关键)。
  • 重载(标准)运算符需要我们实现 `static` 方法,这些方法带有 `operator` 关键字并声明为 `public`。
  • 我们不能重载索引运算符,但可以创建任意多的索引器。索引器通过其签名来区分。
  • 显式转换如下执行:`(double)c5`,隐式转换由编译器自动触发。但是,隐式转换也可以显式使用,例如 `(Complex)2.0`。

Yield 语句

在 C# 中,使用 `foreach` 循环是很常见的,尽管与传统的 `for` 循环相比存在一些显著的缺点。一个主要缺点是循环迭代器的不变性,即我们无法更改循环变量。因此,以下代码**不可能**实现:

var integers = new [] { 1, 2, 3, 4, 5, 6 };

foreach(var integer in integers)
    integer = integer * integer;

这是因为 `foreach` 循环仅与实现 `IEnumerable` 接口的元素一起工作,就像每个数组或集合天然做的那样。一旦某个类实现了这个接口,就可以调用 `GetEnumerator` 方法。它返回一个特殊的类,该类实现了 `IEnumerator` 接口。在这里,我们有一个名为 `Current` 的属性以及 `MoveNext` 和 `Reset` 方法的对象。这个属性是只读的。

现在要看的第一件事是实现我们自己类中的 `IEnumerable` 接口。以下示例应能说明这一点:

class MyClass : IEnumerable<int>
{
    public IEnumerator<int> GetEnumerator()
    {
        return null;
    }
    
    IEnumerator IEnumerable.GetEnumerator()
    {
        return null;
    }
}

我们已经可以在 `foreach` 循环中使用它了。编译器会允许这样做,但是,在运行时我们会遇到严重的问题,因为生成的指令中没有隐含的 `null` 检查。现在我们有两种选择:

  1. 创建一个实现 `IEnumerator` 的类(在本例中 `T` 为 `int`)。
  2. 使用一个不错的 C# 语言特性。

在这里,我们实际上会同时做这两件事,但是,只是为了看看为什么第二种方法比第一种方法更好(更短)。让我们看看第一种方法所需的代码。

class MyClass : IEnumerable<int>
{
    public IEnumerator<int> GetEnumerator()
    {
        return new Squares();
    }
    
    IEnumerator IEnumerable.GetEnumerator()
    {
        return new Squares();
    }

    class Squares : IEnumerator<int>
    {
        private int number;
        private int current;

        public int Current
        {
            get { return current; }
        }
        
        object IEnumerator.Current
        {
            get { return current; }
        }

        public bool MoveNext()
        {
            number++;
            current = number * number;
            return true;
        }

        public void Reset()
        {
            number = 0;
            current = 0;
        }
        
        public void Dispose()
        { }
    }
}

天哪!现在这太长了,而且我们不得不写一整类!在我们详细介绍第二种方法之前,我们先简要看一下它的用法:

var squares = new MyClass();

foreach (var square in squares)
{
    Console.WriteLine(square);

    if (square == 100)
        break;
}

现在我们对更短的实现方式感到兴奋,让我们来看看它,这涉及到(尚未讨论的)`yield` 关键字。

class MyClass : IEnumerable<int>
{
    public IEnumerator<int> GetEnumerator()
    {
        for (int i = 1; ; i++)
            yield return i * i;
    }
    
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

现在这在代码行数上有了很大的减少!当然,这个例子通常会变成一个无限循环。也许这不想要。关键问题是——我们能否在特定点中断循环?有多种方法可以做到这一点。一种方法是像这样限制示例中的 `for` 循环:

public IEnumerator<int> GetEnumerator()
{
    for (int i = 1; i < 10; i++)
        yield return i * i;
}

然而,有时代码会稍微长一些,并且相当复杂。在这种情况下,我们希望返回一些能停止迭代的东西。再次,`yield` 关键字是关键,但是这次是与 `break` 关键字结合使用的。

public IEnumerator<int> GetEnumerator()
{
    for (int i = 1; ; i++)
    {
        var square = i * i;

        if (square > 100)
            yield break;    
            
        yield return square;
    }
}

这让我们可以在以下代码片段中使用代码:

var squares = new MyClass();

foreach(var square in squares)
    Console.WriteLine(square);

最后,我们在 C# 中有了一种创建迭代器的好方法!但是使用迭代器的主要好处是什么,在使用它们之前需要了解什么?

迭代器

迭代器是一种使我们能够遍历容器的对象。我们刚刚看到了创建迭代器是多么容易。前面的示例也应该向我们展示,遍历容器可以是遍历集合(或数组)中的项目列表,但也可以是截然不同的。在前面的示例中,我们的容器不包含任何元素,而是能够生成作为迭代器对象的元素。

C# 中的 `yield` 关键字和迭代器受到了 CLU 编程语言的启发。

任何对象定义的最重要的特征是它实现了 `IEnumerable`(甚至更好:`IEnumerable`)接口。这需要实现 `GetEnumerator` 方法,并允许执行 `foreach` 循环。还有其他好处:由于(P)LINQ 是建立在 `foreach` 循环之上的(所有入口扩展方法都以 `IEnumerable` 为目标),我们可以针对此类迭代器编写查询。

这一部分是关于什么的?正如我们在上一节中看到的,在 C# 中构建自己的迭代器非常容易。我们也看到了 `foreach` 使用这个迭代器概念来遍历给定的容器。然而,即使不使用 `foreach`,我们也可以从这个概念中获益。

让我们以以下几行代码为例:

static void Main()
{
    var myc = new MyClass();
    var it = myc.GetEnumerator();
    Examine(it);
}

static void Examine(IEnumerator<int> iterator)
{
    while (iterator.MoveNext())
    {
        if ((iterator.Current & 1) == 0)
            Even(iterator);
        else
            Odd(iterator);
    }
    
    Console.WriteLine("No more elements left!");
}

static void Even(IEnumerator<int> iterator)
{
    Console.WriteLine("The element is even ...");
}

void Odd(IEnumerator<int> iterator)
{
    Console.WriteLine("The element is odd ...");
}

当然,这段简单的代码也可以使用 `foreach` 循环来编写,但是,示例应该表明我们可以构建一整套方法,这些方法最终只接受一个迭代器并对其进行操作。因此,这段代码可以扩展为以下版本:

void Examine(IEnumerator<int> iterator)
{
    List<int> entries = new List<int>();

    while (iterator.MoveNext())
    {
        entries.Add(iterator.Current * iterator.Current);
    
        if ((iterator.Current & 1) == 0)
            Even(iterator);
        else
            Odd(iterator);
    }
    
    var next = entries.GetEnumerator();
    Console.WriteLine("No more elements left!");
    
    if (entries[entries.Count - 1] < 1000000000)
        Examine(next);
}

在这里,我们再次使用我们的方法——这次是使用从由给定元素平方生成的列表中的迭代器。再次调用 `Examine` 将导致数字变为 4 次方,然后变为 8 次方,依此类推。这意味着我们可以预期如下输出:

The element 1 is odd ...
The element 4 is even ...
// ...
The element 100 is even ...
No more elements left!
The element 1 is odd ...
The element 16 is even ...
// ...
The element 10000 is even ...
No more elements left!
The element 1 is odd ...
The element 256 is even ...
// ...
The element 100000000 is even ...
No more elements left!

迭代器在对容器进行(向前)检查时非常有用。下图再次说明了迭代器模式。应该注意的是,没有什么能阻止我们在这里创建一个循环,使得迭代器永远不会结束(在这种情况下 `MoveNext` 将始终返回 `true`)。

The iterator pattern in C#

唯一需要注意的是 `IEnumerable` 是一个接口。接口可以在类和结构中实现,如果我们收到一个结构,那么传递迭代器可能不会如预期那样工作(因为在没有自定义迭代器的情况下,我们总是收到类,这些类是作为引用传递的)。

让我们看一个这样的问题的例子:

static void Main()
{
    List<int> squares = new List<int>(new int[] { 1, 4, 9, 16, 25, 36, 49, 64, 81, 100 });
    var it = squares.GetEnumerator();//The var is problematic here...
    ConsumeFirstFive(it);//This should move the iterator 5 times ...
    Console.WriteLine("The 6th entry is ... " + it.Current); //This should print 36
}

static void ConsumeFirstFive(IEnumerator<int> iterator)
{
    int consumed = 0;

    while (iterator.MoveNext() && consumed < 5)
        consumed++;
}

预期的结果是 36,但实际结果是 0。这意味着迭代器尚未被移动/触及。怎么会这样?我们使用了 `var` 来隐藏迭代器的实际类型,该类型恰好是 `List.Enumerator` 或 `List` 的嵌套 `struct Enumerator`。

显然,有人认为在 `struct` 中实现迭代器可能有一些优势。事实上确实如此,但是,由于通常生成的 `IEnumerator` 实现是一个 `class`,因此在此阶段混淆可能会成为问题。

理解协变与逆变

C# 中的泛型非常棒,但如果没有协变和逆变,它们并不总能闪耀。以下代码应该能说明这个问题:

abstract class Vehicle
{
    public bool IsParked { get; set; }
}
class Car : Vehicle { }
class Truck : Vehicle { }

void ParkVehicles(IEnumerable<Vehicle> vehicles) 
{ 
    /* some code */ 
}

static void Main()
{
    List<Car> carpool = new List<Car>();
    carpool.Add(new Car());
    carpool.Add(new Car());
    ParkVehicles(carpool);
}

现在从我们的角度来看,很明显,一辆汽车列表是一系列(更专业的)车辆。因此,`IEnumerable` 可以用作 `IEnumerable`。然而,同样的比喻也适用于 `List`。现在我们看到这可能会导致问题,因为如果我们实际上可以将 `List` 用作 `List`,那么我们也可以将 `Truck` 实例存储在其中。因此,我们可以说,我们真正想要的是:

  • 我们想将 `List` 作为 `List` 来读取。
  • 我们不想将 `List` 作为 `List` 来写入。

那这意味着什么?在我们的例子中,我们想要的是 `IEnumerable` 中 `T` 的协变处理。如果某个东西被称作协变,那么它会保留类型的顺序,即从更具体到更通用的。我们从一开始就看到了协变。还记得以下内容吗?

object str = "I am a string stored in a variable of type object!";

相反,我们谈论逆变,如果顺序颠倒,即类型从更通用的排序到更具体的。这在类型系统中不会发生,我们需要写:

string o = new object(); //ouch!

然而,在某些情况下,后者非常有用的。还记得委托吗?通常,我们希望将最不具体的类型放在输入参数上(即,如果一个 `Vehicle` 包含我们需要的所有属性,为什么我们要将参数限制为 `Car`?)并将最具体的类型放在返回参数上(为什么我们要返回 `Object`,而实际类型是 `Car`?)。

因此,输入参数是逆变约束(具体到通用)的完美匹配,输出参数是协变约束(通用到具体)的完美匹配。如果类型必须保持不变(在层次结构中向上或向下移动),则称为不变。

在这种 `IEnumerable` 的情况下,解决方案是这样做:

public interface IEnumerable<out T>
{
    /* ... */
}

这并非与 `List` 一起完成,因为我们也在里面进行写操作。当然,给定的委托也是协变和逆变的(自 C# 4.0 起)。

public TReturn Func<in T1, in T2, ..., out TReturn>(T1 arg1, T2 arg2, ...);

现在关键问题是:

  1. 这内置在哪里?.NET 3.5 以来哪些接口已更新?
  2. 何时使用协变和逆变?

第一个问题有一个简单(但也很深刻)的答案:所有协变或逆变有用的接口都已更新。`IEnumerable` 已更新(协变,即 `IEnumerable`),因为数据只接收。另一方面,像 `List` 这样的接口保持不变,因为数据要在内部更改。我们还有一些逆变接口,例如 `IComparer` 已更改为 `IComparer`。这里数据只发送,使其成为输入参数类行为的完美匹配。

这已经回答了第二个问题的一部分:我们应该为泛型(当然!)使用这个概念,当我们有一个接口(出于可维护性和可能的问题,我们不应该在完整的类上使用它)只包含那些只返回(协变)特定类型参数的函数,或者只接收特定类型参数的函数。

Comparing co-and contra-variance

这个声明也可以扩展到泛型函数和委托。一旦我们构造了一个函数,其类型或签名仅将这些类型作为输入或(这是排他性的!)输出使用,我们就能(从长远来看)通过将类型参数声明为协变或逆变来受益。

有效使用特性

C# 的另一个新特性,实际上是通用语言运行时(CLR)的一部分,是特性的概念。特性是关于代码的元数据,是程序员与编译器和运行时系统之间的通信。在 C 中,通常通过 pragmas 和非标准关键字来完成;在 Java 中,使用标记接口。特性比可比语言中的任何类似功能都要丰富得多。它们还为语言增加了一个非常复杂的方面,许多开发人员将不会用到。幸运的是,可以在不使用特性的情况下编写许多 C# 程序。

让我们回顾一下我们已经见过的特性:当我们介绍 C# 中的枚举时,我们看到了 `[Flags]` 特性,它将枚举标记为位标志。因此,这使得更优化的 `string` 生成,并向其他程序员提示值的组合是支持的,甚至是可以预期的。

另一个非常有用的特性是 `[Serializable]`。此特性告诉编译器给定 `class` 或 `struct` 的实例可以被序列化为字节。我们在这里不详细介绍。

相反,我们将专注于一组声明性特性。在 Windows Forms 开发中,迟早会开始构建自定义控件。我们已经简要触及过这个话题。在介绍创建自定义控件的概念的部分中,有一个问题尚未回答:“集成设计器如何知道属性的类别或默认值?”当我们仔细查看属性对话框时,我们会发现默认情况下,它按属性名称的升序排序。然而,通常的偏好不是仅按名称对对话框中的条目进行排序,而是按控件属性的不同类别进行排序。

这个谜的答案似乎与特性有很强的关联(否则为什么在本节中回答它?)。让我们通过查看一些属性来看看有哪些可用属性:

public class MyControl : Control
{
    [Category("Universe")]
    [Description("The answer to life, universe and everything.")]
    [DefaultValue(42)]
    public int AnswerToEverything
    {
        get;
        set;
    }
}

给出的特性定义在 `System.ComponentModel` 命名空间中。这组特性允许我们在定义自定义控件的属性时完成大部分规范工作。此外,`DefaultProperty`(如果应默认选择给定的属性)、`DefaultEvent`(如果应默认选择给定的事件)、`DisplayName`(设计器中显示的名称)和 `Localizable`(此属性应本地化)在某些场景下非常有用。下图显示了集成设计器中的结果。

A look at our own property with description in the designer

在继续进行更复杂的示例之前,我们需要了解特性究竟是什么以及如何使用它们。特性是一种元数据,由编译器(特殊特性)访问,或者可以通过反射在运行时访问(它又来了!)。这些特性非常棘手。它们与实例无关,因此只与类型相关。

每个特性都必须继承自 `Attribute`。这与 `Exception` 非常相似。然而,与 `Exception` 对象相比,`Attribute` 对象有一些限制。在其主要用法(作为特性)中,它们只能有独立的构造函数参数。因此,我们不能传入委托或与实例相关的引用。原因是特性实例是在编译时创建的,没有运行的应用程序。因此,任何指向应用程序/实例相关变量的引用都无法解析。

说了这些,让我们创建一个非常简单的特性:

[AttributeUsage(AttributeTargets.Class)]
public class LinkAttribute : Attribute
{
    public LinkAttribute(string url)
    {
        Url = url;
    }

    public string Url { get; private set; }
}

有趣的是,我们使用一个特性来标记这个特性。在这里,我们使用 `AttributeUsage` 特性将我们的 `Link` 特性标记为仅适用于类。这样的声明不是必需的,但有时非常有用。

使用此特性按预期工作:

[Link("http://www.google.com")]
public class Google : Webpage
{
    /* ... */
}

我们可以看到,构造函数现在被隐式调用。我们还看到 `Attribute` 一词已被删除。这是一个约定,不是必需的。我们也可以通过使用 `LinkAttribute` 而不是 `Link` 来调用该特性。由于我们自己的特性不代表编译器有意义的内容,因此我们需要手动读取它们。我们已经说过特性和反射是相关的,所以我们知道读取这些特性的方法是使用反射。

public string GetLink(Webpage page)
{
    var attrs = page.GetType().GetCustomAttributes(typeof(LinkAttribute), false);

    if (attrs.Length == 0)
        return string.Empty;

    return ((LinkAttribute)attrs[0]).Url;
}

在这里,我们应该注意到我们明确设置了我们的特性允许多次出现或不允许。默认情况下,一个特性可以出现多次。特性也永远不是必需的,所以总有可能不出现特性。这些情况需要涵盖。

优雅的绑定

现代 UI 应用程序大量使用绑定功能。绑定是一种将一个值耦合到另一个值的方式。在图形用户界面的情况下,我们希望在屏幕上显示的值与正确的实际值之间建立连接。一般来说,这个问题是无法解决的。没有 CPU 指令会在访问特定地址时触发。

因此,这个问题在编程语言中得到解决。在 C/C++ 中,程序员引入了 getter 和 setter 方法。在 C# 中,向程序员提供了属性的概念。我们已经讨论了这种方法的优点。现在我们还有 2 个问题:

  • 在读取值时也可以直接通过字段(在相应的类中)进行,而写入访问应始终通过属性进行。否则,逻辑或更新例程将不会执行。
  • 仅仅拥有一个属性并不能解决问题。该属性还必须触发一些更新逻辑或调用另一个方法。

虽然第一个问题掌握在我们手中(始终记住仅通过属性/以 UI 知晓值已更改的方式更改 UI 依赖值),但第二个问题已不再是我们的问题。为什么呢?

有很多(非常好的)框架或库可以很好地管理这种绑定。我们现在将看一下 WPF UI 框架的内置绑定功能,该框架(在某种程度上)是 Windows Forms UI 框架的后继者。WPF 包含一个名为 `INotifyPropertyChanged` 的接口。如果我们实现了这个接口,我们就需要实现一个名为 `PropertyChanged` 的事件。通常,它看起来像这样:

class MyData : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    void RaisePropertyChanged(string propertyName)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

如果我们现在想将此与属性结合使用,我们会像这样编写代码:

int answer;

public int Answer
{
    get { return answer; }
    set 
    {
        answer = value;
        RaisePropertyChanged("Answer");
    }
}

WPF 框架将处理其余的事情。可以将我们自己的数据类的实例绑定到 UI。WPF 将显示值并允许用户更改它们。每次更改都会导致属性被调用。最后,当(绑定的)属性引发其更改事件时,UI 会更新。

这种方法的缺点是我们总是需要将属性名称写两次。一次作为标识符,第二次作为 `string`。问题不在于额外的输入,而在于(极有可能)拼写错误的风险。由于这只是一个 `string`,我们没有编译器检查给定的 `string` 是否是一个有效的属性,或者它是否是我们实际引用的属性。虽然第一类问题可以通过自动测试来检查,但第二类问题更难检测。

以下代码片段提供了一个很好的解决方案:

protected void RaisePropertyChanged([CallerMemberName] string propertyName = null)
{
    if (PropertyChanged != null)
        PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
}

在这里,我们使用一种特殊的属性,它告诉编译器,如果未提供参数,则用调用者(即属性名称)的(成员)名称替换参数的名称。因此,我们之前的属性可以更改为:

public int Answer
{
    get { return answer; }
    set 
    {
        answer = value;
        RaisePropertyChanged();
    }
}

这个版本更短,更不容易出错。然而,还有第二个版本(不需要属性)。虽然第一个版本简短、快速且健壮,但它也不够灵活,而且相当有限。例如,我们无法以健壮的方式传递其他属性的名称。在这里,我们仍然需要退回到字符串版本,这不太健壮。另一个限制是需要大量工作来读取实际值。这两个问题都可以通过使用 `Expression` 类来解决。

让我们先看一个例子:

public static string GetPropertyName<TClass, TProperty>
(Expression<Func<TClass, TProperty>> expression)
{
    MemberExpression memberExp;

    if (TryFindMemberExpression(expression.Body, out memberExp))
    {
        var memberNames = new Stack<string>();

        do
        {
            memberNames.Push(memberExp.Member.Name);
        } while (TryFindMemberExpression(memberExp.Expression, out memberExp));

        return string.Join(".", memberNames.ToArray());
    }
    
    return string.Empty;
}

static bool TryFindMemberExpression(Expression exp, out MemberExpression memberExp)
{
    memberExp = exp as MemberExpression;

    if (memberExp != null)
        return true;

    if (IsConversion(exp) && exp is UnaryExpression)
    {
        memberExp = ((UnaryExpression)exp).Operand as MemberExpression;

        if (memberExp != null)
            return true;
    }

    return false;
}

static bool IsConversion(Expression exp)
{
    return (exp.NodeType == ExpressionType.Convert || 
            exp.NodeType == ExpressionType.ConvertChecked);
}

此代码片段现在可以处理如下代码:

public int Answer
{
    get { return answer; }
    set 
    {
        answer = value;
        RaisePropertyChanged(this => Answer);
    }
}

将 `RaisePropertyChanged` 更改为:

void RaisePropertyChanged<TClass, TProperty>(Expression<Func<TClass, TProperty>> expression)
{
    if (PropertyChanged != null)
    {
        var propertyName = GetPropertyName(expression);
        PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
    }
}

这个构造函数最大的优点是我们可以毫无问题地获取值。我们所要做的就是评估给定的方法:

static void RaisePropertyChanged<TClass, TProperty>
       (TClass source, Func<TClass, TProperty> expression) where TClass : MyData
{
    if (PropertyChanged != null)
    {
        string propertyName = GetPropertyName(expression);
        var f = expression.Compile();
        TProperty propertyValue = f(source);
        PropertyChanged(source, new PropertyChangedEventArgs(propertyName));
    }
}

在这里,我们结合了几个优点并获得了广泛的解决方案。总结本节:如果我们只需要知道调用属性的名称,我们可以使用编译器属性,否则我们可能想使用基于 `Expression` 的那个。

当然,如果我们不关心属性值,那么前面描述的模式就没有什么意义了。更有意义的是对原始 `RaisePropertyChanged` 实现的改进版本。这里,以下内容作为一个中间层相当好用:

protected bool SetProperty<T>
(ref T field, T value, [CallerMemberName] string propertyName = null)
{
    if (!object.Equals(storage, field))
    {
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }

    return false;
}

此代码片段现在可以处理如下代码:

public int Answer
{
    get { return answer; }
    set { SetProperty(ref answer, value); }
}

作为一般说明,对于 C# 6,当我们只需要提供对应于类型、属性或方法(以及其他)名称的 `string` 时,我们应该使用 `nameof` 运算符。

不安全代码

C# 也有以本机方式直接访问内存的可能性。考虑以下 C 代码,其中 `byte` 是 `char`(1 字节)的类型定义:

int main() {
    int a = 35181;
    byte* b = (byte*)&a;
    byte b1 = *b;
    byte b2 = *(b + 1);
    byte b3 = *(b + 2);
    byte b4 = *(b + 3);
    return 0;
}

这段代码允许我们将 4 字节整数类型拆分为其组成部分。有趣的是,这段代码也是完全有效的 C# 代码!

unsafe static void Main()
{
    int a = 35181;
    byte* b = (byte*)&a;
    byte b1 = *b;
    byte b2 = *(b + 1);
    byte b3 = *(b + 2);
    byte b4 = *(b + 3);
}

不幸的是(或者幸庆的是,我们稍后会看到),这不能立即生效,因为默认编译选项排除了不安全上下文。我们必须在相应项目属性中启用“**允许不安全代码**”选项。相应的对话框如下图所示:

Allowing unsafe code by checking the box in the project's properties

要求此选项有一些原因:

  • 某些平台仅以托管模式运行 C#,禁止任何不安全编译。因此,使用 `unsafe` 块编写的代码将在那里运行。
  • 对象的通用类型是托管的,这意味着它们可以在内存中重新定位。如果我们(固定)指向它们的地址,我们可能会遇到无效内存(段错误),因为这些对象可能在我们不知道的情况下被重新定位了。
  • 不安全代码无法像托管代码那样进行优化。从优化的角度来看,这不像 C 中的汇编代码那么糟糕,但可能会对性能产生负面影响。

然而,不安全代码也可能为一些性能问题提供解决方案。在不安全块中,我们可以迭代数组而无需使用 C# 索引运算符。这很好,因为 C# 索引运算符会通过检查它是否在边界内而产生一些开销。在某些情况下,这可能是一个巨大的问题,例如在分析位图时。

在我们将讨论用于有效使用 `unsafe` 关键字的示例之前,让我们讨论一些 `unsafe` 范围的其他属性(或特性)。我们可以使用 `unsafe` 关键字创建完整的范围、方法或定义(如 `class`)。

unsafe class MyClass
{
    /* pointers can be used here */
}

class MyOtherClass
{
    unsafe void PerformanceCriticalMethod()
    {
        /* pointers can be used here */
    }
}

class MyThirdClass
{
    void ImportantMethod()
    {
        /* some stuff */

        unsafe
        {
            /* pointers can be used here */
        }

        /* more stuff */
    }
}

已经提到过的一个不安全代码的陷阱是使用 `fixed` 关键字来解决的(意指)。

static int x;

unsafe static void F(int* p)
{
  *p = 1;
}

static void Main() 
{
    int[] a = new int[10];
    unsafe 
    {
        fixed (int* p = &x) F(p);
        fixed (int* p = &a[0]) F(p);
        fixed (int* p = a) F(p);
    }
}

`fixed` 语句用于固定数组,以便其地址可以传递给接受指针的方法。这(或 `unsafe` 块的通用用法)一个非常方便的用途是使用指针算术遍历数组。因此,我们可以使用它来填充多维数组中的所有条目,如下面的示例所示:

static void Main()
{
    int[,,] a = new int[2,3,4];
    unsafe 
    {
        fixed (int* p = a)
        {
            for (int i = 0; i < a.Length; ++i)
                p[i] = i;
        }
    }
}

在我们讨论访问位图数据的高效示例之前,关于这种不安全代码的最后一件有趣的事情是可以使用 `stackalloc` 替换 `new` 的可能性。问题如下:如果我们想从调用堆栈分配内存,用于一个基本(或非托管)类型(如 `char`、`int`、`double` 等)的数组?现在,每个数组自然地放置在堆上,由垃圾回收器管理(有一些方法可以绕过它,将在下一节讨论)。

char* buffer = stackalloc char[16];

`stackalloc` 关键字只能在 `unsafe` 上下文中使用。分配的内存会在离开作用域时自动释放。基本上,关键字等同于(从我们的角度来看)`new`,区别在于作用域结束后会立即执行清理,可能导致更少的垃圾回收压力和更好的性能。

这对应于 `alloca` 函数,它是 C 和 C++ 实现中常见的扩展。

回到承诺的示例,通过不安全块在操作图像时获得性能提升。问题是 C# 中对位图数据(在 `Bitmap` 类中)的唯一访问是通过迭代二维数组。每个值然后是一个具有多个颜色值的结构。整个情况自然取决于位图本身(例如,每种颜色多少字节)。

var path = @"1680x1050_1mb.jpg";
var bmp = (Bitmap)Bitmap.FromFile(path);
var sw = Stopwatch.StartNew();

for (var i = 0; i < bmp.Width; i++)
{
    for (var j = 0; j < bmp.Height; j++)
    {
        bmp.GetPixel(i, j);
    }
}
        
sw.Stop();
Console.WriteLine(sw);

这段(无意义的)代码在我机器上大约需要 2550 毫秒。让我们使用一些不安全的代码来加速它!

var path = @"1680x1050_1mb.jpg";
var bmp = (Bitmap)Bitmap.FromFile(path);
//This is basically a (managed) call to fix the bitmap (no memory movement)
var data = bmp.LockBits(new Rectangle(Point.Empty, bmp.Size), 
           ImageLockMode.ReadOnly, bmp.PixelFormat);
//This gets the number of bytes per pixel, usually the same as the number of colors ...
var bpp = data.Stride / data.Width;
var sw = Stopwatch.StartNew();

unsafe
{
    //This gets the base pointer address - where we can start to scan
    byte* scan0 = (byte*)data.Scan0.ToPointer();
    //This is just a duplicate, however, the one which will be moved
    byte* scan = scan0;
    
    for (var i = 0; i < bmp.Width; i++)
    {
        for (var j = 0; j < bmp.Height; j++)
            scan += bpp;
    }
}
        
sw.Stop();
//Here we free the bitmap, so that it can be moved again
bmp.UnlockBits(data);
Console.WriteLine(sw);

在我机器上,这段代码运行时间为 335 毫秒,大约快 8 倍。最大的缺点之一是我们仍然有 2 个循环。当然,这可以合并成一个循环,但是,我们现在想在不进入非托管代码的情况下变得更快。

我们如何实现这一点?魔术词是:互操作性(或简称 interop)。COM 互操作是 .NET CLR 中包含的一项技术,它使 COM 对象能够与 .NET 对象交互,反之亦然。我们可以使用它来让本机代码执行一些数组复制。之后,我们在线性数组中访问整个图像的字节数据。

var path = @"1680x1050_1mb.jpg";
var bmp = (Bitmap)Bitmap.FromFile(path);
//Same as before - this time required for the interop
var data = bmp.LockBits(new Rectangle(Point.Empty, bmp.Size), 
           ImageLockMode.ReadOnly, bmp.PixelFormat);
//We still need to know how many bytes to skip per pixel
var bpp = data.Stride / data.Width;
//Now we need the number of bytes - data.Stride is a whole line
var bytes = data.Stride * bmp.Height;
//Just to do something in the loop
byte value;
var sw = Stopwatch.StartNew();

//Get the start address - this time it is enough to stay with the IntPtr type
var ptr = data.Scan0;
//Create an empty byte array - this will be the destination
var values = new byte[bytes];
//Marshal is a static class with interesting interop calls
Marshal.Copy(ptr, values, 0, bytes);
    
for (var i = 0; i < bytes; i += bpp)
    value = values[i];
        
sw.Stop();
//Again unlocking is important
bmp.UnlockBits(data);
Console.WriteLine(sw);

我们从 `IntPtr` 实例指定的给定地址开始复制。作为目标,我们将 `values` 字节数组传递进去,偏移量为 0。总共,我们希望接收 `bytes` 字节。

这段代码运行时间仅为 14 毫秒,比之前快约 20 倍,比第一个版本快 160 倍。尽管有这种性能提升,但我们仍然应该注意到,在这三种情况下都没有进行实际工作,因此第二段或第三段代码都有其优点和缺点。

第三个问题是图像的更改必须传输回原始数据源。所以这里我们有很多内存传输,中间有重复的内存。

最快的解决方案是使用带有属性缓存的不安全变体。小调整是在 `unsafe` 块中使用以下代码:

byte* scan0 = (byte*)data.Scan0.ToPointer();
byte* scan = scan0;
var width = bmp.Width;
var height = bmp.Height;

for (var i = 0; i < width; i++)
{
    for (var j = 0; j < height; j++)
        scan += bpp;
}

现在我们只需要 2 毫秒多一点,这在速度上接近另一个数量级,比原始版本快近 3 个数量级。事实证明,真正的性能优化一直就在我们眼前——缓存 C# 属性访问以避免(冗余的)昂贵计算。

原生代码与托管代码之间的通信

我们在上一节的最后已经触及了 COM 互操作的主题。在本节中,我们想看看我们实际上如何使用它以及何时 COM 互操作变得非常有用。我们不会创建自己的 COM 互操作组件或接口。

COM 互操作是从 C# 访问本机应用程序函数的一种可能方式。因此,它也是从 C# 访问 Win32 API 的方式。由于 Windows 内核大部分是用纯 C 编写的,因此 API 也主要是 C 风格的。这意味着我们有大量的参数,返回值有时也以(用 C# 的话来说:`out`)参数的形式给出。

现在在我们转向代码之前,我们必须认识到一些真相:

  • 许多 API 调用基于常量值(来自枚举)。问题是 C 不支持像 C# 那样封装枚举值,导致我们必须知道常量的确切值。这意味着我们需要在 C# 中(至少部分地)重写枚举(有时只需知道一个常量就足够了,但大多数时候我们对多个值感兴趣)。
  • C 也不支持 `delegate` 类型。然而,有时我们必须传入回调函数。这些函数需要某种签名,而该签名不像 C# 中的委托那样被显式检查。无需多言,我们应该传递正确的签名,这意味着我们还必须构建所需的委托(除了枚举)。
  • 一些 API 调用大量使用结构。在 C 中,封装数据的唯一方法是通过形成结构。我认为从以上两点可以很明显地看出,我们必须在 C# 中重建这些 `struct` 类型。

最后一点实际上非常有趣。虽然 `delegate` 只是确保特定签名的一种方式,而常量只是一个数字,但 `struct` 是一个具有内存地址的实际对象。这个对象可能会被垃圾回收器移动,或者(更可能也更糟糕)结构不同于 C 中的结构。如果我们构造 C 中的以下 `struct`(代码以 C# 形式给出):

struct Example
{
    public int a;//4 bytes
    public double b;//8 bytes
    public byte c;//1 byte
}

我们知道它将是 13 字节。更重要的是,我们也知道前 4 个字节代表一个整数,接下来的 8 个字节是一个双精度浮点数,最后一个字节是一个单字节。这个顺序在 C 中是保证的,但是在 C# 中不是。无需多言,我们需要保证的顺序才能与用 C 编写的 API 进行通信。

有一个巧妙的技巧可以在 C# 中实现这一点。实际上它不是一个技巧,它只是一个属性,用来告诉编译器保留顺序。

[StructLayout(LayoutKind.Sequential)]
struct Example
{
    public int a;//4 bytes
    public double b;//8 bytes
    public byte c;//1 byte
}

根据官方规范,此属性会自动应用于结构,至少在它们用于互操作时。尽管如此,建议显式使用该属性,以确保一切都能按预期工作。`StructLayoutAttribute` 属性还有其他用法。另一种流行的方法是手动指定每个字段:

[StructLayout(LayoutKind.Explicit, Pack=1, CharSet=CharSet.Unicode)]
struct Example
{
    [FieldOffset(0)]
    public int a;
    [FieldOffset(4)]
    public double b;
    [FieldOffset(12)]
    public byte c;
}

这也可以用于在 C# 中生成类似联合的结构:

[StructLayout(LayoutKind.Explicit, Pack=1)]
struct Number
{
    [FieldOffset(0)]
    public int Value;
    [FieldOffset(0)]
    public byte First;
    [FieldOffset(1)]
    public byte Second;
    [FieldOffset(2)]
    public byte Third;
    [FieldOffset(3)]
    public byte Fourth;
}

/* Code to use */
var num = new Number();
num.Value = 482912781;
/* In this case we have First = 13, Second = 170, Third = 200, Fourth = 28 */

联合不是一个新概念。事实上,联合在 C 中很常见。下图展示了内存中发生的情况:

Unions have various representations for the same data

现在我们对如何构造互操作调用参数有了一个印象,我们应该看一个实际的互操作调用:我们想访问本机的 GDI API 来读取屏幕上某个像素的特定值(颜色)。

[DllImport("gdi32.dll")] //GDI is defined in gdi32.dll
 static extern uint GetPixel(IntPtr handle, int x, int y); //uint GetPixel(int*, int, int) 
                                                           //is the signature

就这样!如果我们现在调用 `GetPixel` 方法,系统将查找文件 `gdi32.dll`。它首先在当前目录中查找,然后继续在所有设置的路径中查找。最后,它将在 Windows 本身 的 `System32` 目录中找到。DLL 加载后,无需再次加载。

这里的一切似乎都很清楚,但是等等——我们想要获取像素的句柄地址是多少?如果是屏幕,我们如何获得地址?每个 `int*` 都打包在一个托管的 `IntPtr` 实例中。我们知道可以通过 `Handle` 属性获取任何 `Control` 的句柄,但是我们没有“屏幕”控件。

还需要导入两个方法来获取此句柄:

[DllImport("gdi32.dll")]
static extern IntPtr CreateDC(string driver, string device, string output, IntPtr data);

[DllImport("gdi32.dll")]
static extern bool DeleteDC(IntPtr handle);

现在我们可以编写所需的函数来获取屏幕上任意像素的颜色。

public Color GetScreenPixel(int x, int y)
{
    var screen = CreateDC("Display", null, null, IntPtr.Zero);
    var value = GetPixel(screen, x, y);
    var pixel = Color.FromArgb(value & 0xFF, 
                (value & 0xFF00) >> 8, (value & 0xFF0000) >> 16);
    DeleteDC(screen);
    return pixel;
}

需要进行整数转换,因为 API 返回的颜色格式是 ABGR(红蓝交换)。如果我们想使用此方法截屏,我们会发现每个 API 调用都需要一些时间,导致性能非常差。即使抓取 32x32 像素(1024 次 API 调用)也需要大约 20 秒。这意味着每次 P/Invoke 调用我们花费约 20 毫秒。

DLL 导入属性构造函数还有其他一些有趣的选项,例如 `CallingConvention`,它使用 `CallingConvention` 枚举。在这里,我们有 `StdCall`(默认值)、`Winapi`(采用操作系统的默认值)或 `Cdecl` 等选项。后者支持可变数量的参数,即,它最适合具有可变数量参数的 C 函数。

[DllImport("msvcrt.dll", CharSet=CharSet.Unicode, CallingConvention=CallingConvention.Cdecl)]
public static extern int printf(String format, int i, double d); 

此外,还有 `ThisCall`,可用于与 C++ 方法通信。这里,指向类本身的指针(`this`)是第一个参数——根据约定,它将被放置在一个特殊的寄存器中。

还有很多事情要说(一如既往),但总而言之,一切都建立在本节讨论的基础之上。总结如下:

  • 在与本机代码通信时,始终考虑仅使用最接近原始对象的对象和数据类型。如果我们传入类实例和其他 CLR 对象,则出现异常或错误返回值的可能性非常大。
  • 在进行本机调用之前,应三思而后行。如果这样做,应该只做几次。因此,寻找已有的托管代码替代方案总是一个不错的开始。
  • 始终考虑目标系统上是否缺少所需 DLL 的可能性,或者库名称中是否有拼写错误。最好在这里仔细检查两次。

参考资料包括指向 PInvoke.net 页面的链接。这里描述了最常见的 Windows API 调用以及互操作代码片段。这是一个无需花费数小时查阅 MSDN 即可访问 API 等内容的绝佳来源。

高效 C#

编写高效 C# 代码最重要的规则是记住我们实际上受限于具有(一些)开销和托管内存的 CLR 类型。现在我们考虑实际的性能优化,我们应该始终在优化之前尊重代码的可维护性和可读性。在一切正常工作之后,我们可以查看实际性能。如果结果令人满意,就没有理由继续工作。否则,我们可以使用 `PerfView` 等工具进行进一步调查。

`PerfView` 允许我们调查 CPU 使用率、托管内存、阻塞时间和热点调查。在这里,我们直接看到代码中的所谓热路径。这非常重要,因为我们不想在优化不重要的函数或算法上浪费时间。一旦我们开始进行一些优化,我们应该考虑以下(不完整的)列表中的项目:

  • 优化参数
  • 使用 `static` 字段和 `static` 方法
  • 减少函数调用并使用 `switch`
  • 扁平化数组
  • 指定集合的容量
  • 使用 `struct` 或停止使用它
  • 减少 `string` 实例化并减少 `ToString` 调用
  • 了解何时使用 `StringBuilder` 并重用它

第一项,优化参数,旨在将参数数量减少到最少所需。即使这听起来微不足道,但通常做得不正确。考虑以下代码:

int Multiply(int x, int y)
{
    return Multiply(x, y, 1);
}

int Multiply(int x, int y, int z)
{
    return x * y * z;
}

当然,我们在这里一次犯了多个性能错误。首先,我们没有使用 `static` 方法,即使这些方法不依赖于任何全局变量。此外,我们在 `Multiply` 中调用另一个方法 `Multiply`。此外,最后一个还将更多元素放在堆栈上(`x` 和 `y` 的另一个副本),以及(在这种情况下)不需要的变量 `z`。让我们改进一下:

static int Multiply(int x, int y)
{
    return x * y;
}

static int Multiply(int x, int y, int z)
{
    return x * y * z;
}

当然,这使得代码的可维护性降低(如果我们更改了具有三个参数的方法中的某些内容,我们很可能需要在两个参数的版本上也进行此更改),但是,性能已得到优化。我们也可以考虑使用 `ref` 参数来防止局部副本,但那样(而不是值)我们只会收到指针的副本(在(事实上的标准)64 位系统上,由于 `int` 是 4 字节而指针是 8 字节,这会更大)。

当我们调用任何未被 JIT 内联的方法时,将使用变量的副本(对于 `struct` 变量是值,对于 `class` 变量是指针)。这会导致堆栈内存操作。因此:最小化参数数量,甚至在被调用方法中使用常量而不是将它们作为参数传递会更快。

我们在上面的示例中看到的另一个优点是 `static` 方法的使用。我们应该始终将独立于实例的方法标记为 `static`(不使用全局变量或依赖于实例的方法)。`Static` 字段也比实例字段更快,原因与 `static` 方法比实例方法快相同。当我们加载 `static` 字段到内存时,运行时无需解析实例表达式。`Static` 方法不需要运行时设置 `this` 指针。

内联方法是 C/C++ 开发人员非常熟悉的。在 C# 中,不存在 `inline` 指令或关键字。然而,JIT 在某些情况下会进行一些内联,这通常是保守的。它不会内联中等或大型方法,并且强烈倾向于 `static` 方法。

一个非常重要的性能优化是大量使用 `switch`。`switch` 类似于 `if`,但灵活性较差,更像汇编语言。让我们比较以下两个代码片段:

if (a == 3)
{
    /* Block 1*/
} 
else if (a == 4)
{
    /* Block 2 */
}
else
{
    /* Block 3 */
}

switch(a)
{
    case 3:
        /* Block 1 */
        break;
    case 4:
        /* Block 2 */
        break;
    default:
        /* Block 3 */
        break;
}

第一个问题是关于可读性:这真的取决于个人品味,但是,我认为 `switch` 版本有一些优点。真正的优势在于其性能。虽然 `if` 版本内部会进行多次比较和跳转,但 `switch` 只会进行一次大规模的比较和跳转。使用跳转表使 `switch` 比一些 `if` 语句快得多。此外,对 `string` 使用 `char` switch 非常快。总的来说,我们可以说使用 `char` 数组有时是构建和检查 `string` 的最快方法。

数组的话题让我们了解通过扁平化数组进行潜在的性能优化。在一个短的微基准测试中,我们可以看到多维数组相对较慢。因此,创建一个一维数组并通过算术访问它可以显著提高性能。这种维度减小称为数组扁平化。

Converting a 2D array to a 1D array

让我们看一个计算双精度浮点数二维数组之和的例子:

double ComputeSum(double[,] oldMatrix)
{
    int n = oldMatrix.GetLength(0);
    int m = oldMatrix.GetLength(1);
    double sum = 0.0;

    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++)
            sum += oldMatrix[i, j];

    return sum;
}

double ComputeSum(double[] matrix)
{
    int n = matrix.Length;
    double sum = 0.0;

    for (int i = 0; i < n; i++)
        sum += matrix[i];

    return sum;
}

对于集合,我们应该尝试找到可选容量参数的最佳值。此参数可以影响初始缓冲区大小。一个好的选择有助于避免追加元素时进行大量分配。总的来说,集合比固定数组慢。`List` 比 `double[]` 慢 50% 到 300%,具体取决于编译优化。

一个关键且困难的性能优化是使用结构的话题。虽然它们可以通过减少不同对象的数量来提高 GC 的性能,但它们总是通过值传递给方法。因此,微软应用规则:`struct` 类型的数据不应超过 16 字节。它们也只应用于创建真正基本的数据类型。

频繁使用 `ToString` 方法效率非常低。首先,这是另一个方法调用(有一定的价格标签),此外,我们还在创建 `string` 实例!此分配需要一些内存并给 GC 带来一些压力。下面展示了一个非常低效的例子:

static bool StringStartsWithA(string s)
{
    return s[0].ToString() == "a" || s[0].ToString() == "A";
}

一个改进得多的版本看起来像这样:

static bool StringStartsWithA(string s)
{
    return s.Length > 0 && char.ToLower(s[0]) == 'a';
}

这看起来改进不大,但实际上我们做了很多事情:

  • 我们减少了 2 次 `ToString` 调用。
  • 我们省略了 2 次 `string` 创建。
  • 我们使代码更稳定(如果 `string` 为空怎么办?)(**注意**:检查 `null` 也可以很好)。
  • 我们正在比较字符而不是字符串。

这些 `string` 转换非常棘手。我们应该尽量避免它们。例如,我们可能需要确保 `string` 被小写。如果 `string` 已经是小写的,我们应该完全避免分配新的 `string`。这与上面相同的情况,只是我们不是与单个字符比较,而是与单个 `string`(小写)进行比较。这应该逐个字符地进行。

我们已经讨论过,`StringBuilder` 对象比使用 `Concat` 和 `string` 一起追加 `string` 要快得多。但是,`StringBuilder` 仅在处理大 `string` 或大量 `string` 时才远优于此。如果我们只有几次连接或非常小的 `string`(2 个或几个字符),那么创建一个完整的 `StringBuilder` 实例就太多了。如果我们经常需要使用 `StringBuilder`,我们应该考虑创建一个 `StringBuilder` 池,其中只使用几个实例(2 到 3 个通常足够了)。然后可以回收这些实例。

StringBuilder pooling is the most efficient method

最后但并非最不重要的一点是,我们还可以考虑替换代码中的除法或为许多使用的 `int` 值创建 `string` 表。这些都是非常有效的更改,但实现起来非常耗时。C# 在除法运算方面相对较慢。一种替代方法是使用乘法-移位操作来替换除法以优化性能。因此,我们得到类似以下内容:

static int Divide(int num)
{
    return num / div;//div has to be a constant value - we have to know it
}

static int MulShift(int num)
{
    return (num * mul) >> shift; //if div above is known then mul and shift 
                                 //can be inferred by us
}

现在最困难的部分是为特定的 `div` 值计算 `mul` 和 `shift`。总的来说,最好尽可能避免除法。

static Point3 NormalizePointInefficient(Point3 p)
{
    double norm = Math.Sqrt(p.X * p.X + p.Y * p.Y + p.Z * p.Z);
    return new Point(p.X / norm, p.Y / norm, p.Z / norm);
}

static Point3 NormalizePointImproved(Point3 p)
{
    double norm = 1.0 / Math.Sqrt(p.X * p.X + p.Y * p.Y + p.Z * p.Z);
    return new Point(p.X * norm, p.Y * norm, p.Z * norm);
}

总之,我们可以说性能优化高度依赖于所编写的应用程序。通常(在 C# 中),我们希望在可能的情况下保持可读性,但是,如果我们遇到性能瓶颈,我们仍然有一些强大的武器来解决这些问题。最重要的概念是找出哪些方法消耗了性能,并按优先级列表优化它们。

Outlook(展望)

这次没有展望!除非我将来以某种方式改变主意,否则这将是本 C# 编程教程系列的最后一部分。我希望我能向您展示 C# 是一种现代、高效(按项目花费时间衡量)且优雅的编程语言,它提供了清晰的结构和强大的代码基础。

即使 C#(通常)生成托管代码,我们也能引入一些优化并使用(外部)本机代码来处理性能关键部分。如今,人们正在玩弄 C# 到本机代码的编译器,并且似乎即使整个操作系统也可以完全用 C# 开发,而不会有巨大的性能损失。

Other Articles in this Series(本系列其他文章)

  1. Lecture Notes Part 1 of 4 - An Advanced Introduction to C#(讲义 第一部分 - C# 高级入门)
  2. Lecture Notes Part 2 of 4 - Mastering C#(讲义 第二部分 - C# 精通)
  3. 讲义 第 3 部分(共 4 部分)- C# 高级编程
  4. Lecture Notes Part 4 of 4 - Professional Techniques for C#(讲义 第四部分 - C# 专业技术)

参考文献

历史

  • v1.0.0 | 初始发布 | 2016 年 4 月 21 日
  • v1.0.1 | 添加了文章列表 | 2016 年 4 月 22 日
  • v1.0.2 | 更新了一些错别字 | 2016 年 4 月 23 日
  • v1.1.0 | 更新了结构(带锚点) | 2016 年 4 月 25 日
  • v1.1.1 | 添加了目录 | 2016 年 4 月 29 日
  • v1.2.0 | 感谢 Christian Andritzky 指出位图性能问题 | 2016 年 5 月 10 日
  • v1.3.0 | 感谢 Alexey KK 提到 `INotifyPropertyChanged` 问题 | 2016 年 5 月 13 日
© . All rights reserved.