精通 C# - 讲义 第二部分(共 4 部分)






4.91/5 (71投票s)
在第二部分中,我们将学习泛型、Lambda 表达式、扩展方法和 GUI 编程。
这些文章代表讲义,最初是作为 Tech.Pro 上的教程提供的。
目录
- 引言
- 枚举
- 委托(Delegates)
- 自动属性
- 泛型类型
- 泛型方法
- 约束
- Lambda 表达式
- 匿名对象和类型推断
- 扩展方法
- Linq
- Windows Forms 开发
- Windows Forms 中的自定义绘制
- Outlook(展望)
- Other Articles in This Series(本系列其他文章)
- 参考文献
- 历史
本教程旨在对 C# 编程进行简明而高级的介绍。理解本教程的前提是具备编程、C 语言以及一些基础数学知识。一些 C++ 或 Java 的基础知识可能会有帮助,但不要求必须掌握。
引言
这是 C# 系列教程的第二部分。在本部分中,我们将讨论 C# 的更高级特性,如使用泛型编写模板、以 Lambda 表达式的形式创建匿名方法以及使用扩展方法扩展现有类型定义。最后,我们将通过使用 Windows Forms UI 框架创建图形用户界面来应用我们现有的知识。
有关更多阅读,最后将提供参考文献列表。参考文献将对本教程中讨论的某些主题进行更深入的探讨。
枚举
在 C# 中,我们有几种创建类型的方式。在上一篇教程中,我们了解了如何创建 struct
、class
和 interface
类型。此外,我们还有委托和枚举。尽管如此,结构(值)类型和类(引用)类型之间的主要区别仍然适用,因为例如,任何枚举都只是一种特定结构类型的集合。那么什么是枚举呢?
枚举是常量的集合。与其定义一个静态类并添加公共常量,不如使用一种更清晰、更美观的语法,它还提供了一些好处。
enum MyEnumeration
{
None = 0,
First = 1,
Second = 2,
Last = 100
}
这定义了一个整数枚举。我们可以简单地通过使用 MyEnumeration.None
、MyEnumeration.Last
或其他方式来访问它们。默认情况下,枚举是整数类型,但是可以通过使用继承运算符 :
来更改,如下例所示:
enum AnotherEnumeration : byte
{
Zero,
One,
Two,
Eleven = 11,
Twelve
}
这里每个常量的值是多少?C# 编译器会自动确定。在这种情况下,Zero
将是 0,One
将是 1,Two
将是 2。由于我们告诉编译器将 Eleven
设置为 11,它将自动将 Twelve
设置为 12。此外,每个值都是 byte
类型。
与使用普通常量或整数相比,使用枚举具有很大的优势。枚举是强类型的,并且通过 ToString
方法具有改进的 string
表示形式。此外,将枚举用作方法的参数比使用普通整数或类似类型更受推荐。优点是任何其他程序员都能看到哪些值是有效的以及它们代表什么。
枚举还支持众所周知的按位运算,如与 (&
)、或 (|
) 或异或 (^
)。通常,如果枚举被用作位标志集合,我们会用一个属性来标记它。在本教程中,我们不讨论属性,但如果有用,我们将讨论一些特定的属性。应用属性的操作方式如下例所示:
[Flags]
enum MyBitEnum
{
None = 0,
One = 0x1,
Two = 0x2,
Four = 0x4,
Eight = 0x8,
Sixteen = 0x10,
Thirtytwo = 0x20
}
应用此属性有几个优点。首先,我们获得了改进的组合字符串表示形式。当我们对 MyBitEnum.One | MyBitEnum.Two
应用 MyBitEnum
的 ToString
方法时,我们看到结果字符串是“One, Two”。如果我们未使用 Flags
属性,我们只会得到一个字符串,显示“3
”。另一个优点是任何人都可以识别该枚举与按位运算一起使用,从而组合了指定的条目。
讨论完枚举之后,我们现在将看看 C# 中可以指定的另一种类型:delegate
。
委托
为了保持一切面向对象、强类型和清晰,我们需要引入另一种数据类型来传输方法的引用。这种数据类型是另一种引用类型,必须用于接受方法作为参数。创建委托类型的基本语法与创建抽象方法非常相似:
delegate [return type] [delegate name]([parameter types and names])
总而言之,委托就像一个托管函数指针。此时可能会出现一个问题,即为什么需要为参数指定名称。一个小例子将说明必须命名参数的好处:
delegate double OneDimensionalFunction(double x);
public double Integrate(double start, double end, OneDimensionalFunction f)
{
/* ... use f like a method, i.e., f(x) with any double x */
}
如果我们现在在 Integrate
方法中使用 OneDimensionalFunction
委托的实例,我们会看到一些帮助。我们不仅会看到方法 f
需要一个 double
参数,而且我们还将其命名为“x
”。这个名称现在可以提示预期的用法。因此,我们看到这可用于区分使用相同签名的委托。将上面的委托与此进行比较:
delegate double MapSegment(double position);
即使签名相同,我们也直接看到此委托是为不同目的定义的。在 .NET 框架中,有多个具有相同签名但用途不同的委托。一个非常流行的签名是没有返回类型,并且 object
是单个参数。
一旦我们引入 Lambda 表达式,我们将更频繁地使用委托。
自动属性
显然,C# 编译器为我们做了很多事情,比如在未指定默认构造函数时自动生成一个。在上一篇教程中,我们已经介绍了编写属性来包装变量的概念。变量永远不应使用 public
修饰符。只有出于特定目的才应使用带有 internal
或 protected
修饰符的变量。相反,应使用属性来允许其他对象访问变量。优点是属性可以控制流,即它们可以限制对读或写的访问,在设置变量值之前评估值,或者在变量值更新后执行操作。
考虑到这些论点,似乎应该始终使用属性,如下面的结构所示:
private int myVariable;
public int MyVariable
{
get { return myVariable; }
set { myVariable = value; }
}
这似乎要输入很多内容。实际上,有一种更快的方法,可以使用 Visual Studio(总有一种更快的方法可以使用 Visual Studio)。一旦我们指定了一个变量,就可以右键单击它并选择“重构,封装字段”。这将创建一个属性,并遵循 .NET 的常规约定,即属性以大写字母开头,而不是用于变量名的前导小写字母。
但是,还有一种更短的方法,不依赖于 VS(重构或代码片段):使用 C# 的自动属性。上面的结构也可以写在一行中:
public int MyVariable { get; set; }
这将为我们提供一个名为 MyVariable
的属性,其他对象可以读写该属性。使用此类自动属性,我们将无法访问幕后的变量,因为 C# 编译器将分配一个在编译前未知的名称。此类自动属性需要 get
和 set
块,但是,与普通属性一样,我们仍然可以调整每个块的访问修饰符:
public int MyVariable { get; private set; }
这实际上是最常用的自动属性定义之一(public
getter 和 private
setter)。在这里,我们可以将变量的修改限制在对象内部,同时仍将值对外可见。
尽管自动属性非常有限(我们无法为一个块编写特定指令,同时让另一个块处于自动模式),但它们仍然提供了一个推荐的起点结构。因此,一旦我们需要更高级的指令,例如在设置值之前进行验证,我们只需添加构建普通属性所需的所有语句。
我们必须注意的一点是,abstract
属性看起来与自动属性相同。唯一的区别是 abstract
关键字。让我们看一个例子:
public abstract MyBaseClass
{
public abstract int MyVariable { get; set; }
}
这看起来与我们上面的自动属性非常相似。唯一的区别是 abstract
关键字,它只能在 abstract
类中使用。
因此,总结一下这个主题:自动属性是一个很好的起点,在大多数情况下已经足够。它们可以轻松修改以提供更高级的功能。一般的建议是尽可能多地使用它们,因为我们永远不应该直接向其他对象公开变量以供访问。
泛型类型
C# 为我们提供了两种提供可重用代码的方式。一种是指定结构或类,然后可以实例化它们。另一种方式是指定一个类型模板,然后可以使用其他值进行实例化。这些模板称为泛型,与 C++ 的模板非常相似,但功能要弱得多。优点是它们更直接,更容易理解。
这些泛型的目标是与各种类型一起使用特定类型。让我们先考虑一个例子:在 .NET 框架中有一个名为 ArrayList
的类。这是一个列表,本质上是一个可以调整大小的数组。任何 .NET 数组的 Length
属性都已重命名为 Count
,它提供了列表中元素的当前数量。此外,我们还有 Add
或 Remove
等方法。
现在 ArrayList
是一个非常通用的列表,只接受 object
类型(任何对象)的实例。问题是我们因此只得到 object
类型的对象。起初这似乎不是问题,因为我们可以将对象转换回它们的特定类型,但是,由于在添加新对象时没有执行类型检查,这可能会导致异常。
现在我们可以创建一个新的类,该类在内部使用 ArrayList
。然后,该类将只接受特定类型的对象,并且只返回相同类型的对象。此时问题似乎已解决,但是一旦我们想使用我们的类与其他类型一起使用,我们就需要用其他类型重新完成所有先前的步骤。 .NET 框架的设计者做了类似的事情,并提出了像 StringCollection
(用于 string
类型)或 NameValueCollection
(用于存储键/值对)这样的类。
此时,我们应该已经看到这项工作需要比应有的更多的复制/粘贴。解决方案是使用泛型来创建模板类。任何泛型类型都用尖括号 (<
和 >
) 指定。尖括号指定类型参数。以下是一些示例:
class MyGenericOne<T>
{
}
class MyGenericTwo<T, V>
{
}
class MyGenericThree<TOne, TTwo, TThree>
{
}
第一个类 MyGenericOne
有一个名为 T
的类型参数。第二个类 MyGenericTwo
有两个名为 T
和 V
的类型参数。第三个类 MyGenericThree
有三个名为 TOne
、TTwo
和 TThree
的参数。目前,这些参数中的任何一个都可以由任何类型表示。让我们看看如何使用这些参数:
class MyGenericOne<T>
{
T myvariable;
public MyGenericOne(T myvariable)
{
this.myvariable = myvariable;
}
public T MyVariable { get { return myvariable; } }
public override string ToString()
{
return myvariable.ToString();
}
}
因此,参数类型 T
just acts as a normal type. The only thing that we will recognize right now is that we only have the possibilities of object
on the myvariable
instance. This is due to the fact that T
can be everything, i.e., T
could be the most general object, which is of type object
. We will see later how we can restrict type parameters and therefore enable more possibilities on instances of parameter types.
既然我们已经了解了如何创建泛型类型,我们就必须知道如何使用它们。泛型类型的使用方式与普通类型相同,唯一的区别是我们必须指定类型参数。
MyGenericOne<int> a = new MyGenericOne<int>(5);
一旦我们使用泛型类型,运行时将查找是否已经为其实例化了特定类型。如果尚未实例化,它将从泛型类型创建一个类实例,该实例基本上用给定的类型(s) 替换类型参数。 .NET 框架中一个非常好的例子是泛型 List<T>
类。这解决了我们最初创建几个功能相同的类的麻烦。 List<T>
是一个强类型的集合(列表),它随着 .NET 框架版本 2 一起引入。如果我们使用 List<int>
、List<double>
,将从泛型类型实例化两个类。但是,虽然泛型是运行时功能,但编译器仍可能执行优化,导致总体上(几乎)没有性能损失。
因此,以下图片应包含标签“运行时生成的类”,但在大多数情况下,副标题对于我们的目的来说已经足够准确。
目前,我们只看了类,但泛型实际上可以与大多数类型一起使用。它们对于枚举没有意义,但对于接口、结构或委托可能很有用。让我们看一些泛型委托:
delegate TReturn Func<TReturn>();
delegate TReturn Func<TArg, TReturn>(TArg arg);
delegate TReturn Func<TArg1, TArg2, TReturn>(TArg1 arg1, TArg2 arg2);
有了这三个泛型委托,我们就可以为任何返回非 void
、接受零、一个或两个参数的方法提供可重用的委托。这是一个非常合理的概念,以至于 .NET 框架已经包含泛型委托,称为 Func
(返回非 void
)、Action
(返回 void
)和 Predicate
(返回 bool
)。因此,创建委托的唯一原因应该是给其他程序员一个提示方法应该做什么。否则,我们只能使用给定的泛型委托。
泛型方法
目前,泛型的概念似乎仅限于类型,但是,我们也可以将此概念应用于单个方法。因此,不仅可以生成类、结构、接口或委托,还可以生成方法。语法类似,但用法(通常)要简单得多,因为编译器(大部分)可以推断泛型方法的类型。让我们看一个例子,一个泛型 Swap
方法:
class MyClass
{
public static void Swap<T>(ref T l, ref T r)
{
T temp = r;
r = l;
l = temp;
}
}
如果我们现在想使用这个方法,我们可以简单地使用 Swap
而不指定类型,例如 int
:
int a = 3;
int b = 4;
MyClass.Swap(ref a, ref b);
编译器能够推断类型为 int
并生成所需的方法。此时可能立即出现的一个问题是:如果编译器能够在此处确定类型,为什么编译器无法确定类型在我们上面的示例中,即 MyGenericOne
像 new MyGenericOne(5)
而不是 new MyGenericOne<int>(5)
。答案很简单:编译器能够在此类特殊情况下推断类型,但可能存在另一个名为 MyGenericOne
的类,如下面的代码所示:
class MyGenericOne
{
}
class MyGenericOne<T>
{
}
这是可能的,并且导致结论是 new MyGenericOne(5)
指向非泛型类型 MyGenericOne
,而 new MyGenericOne<int>(5)
指向泛型版本。因此,此处类型推断不起作用,因为编译器(在某些情况下)可能不知道我们指的是哪个。
约束
泛型本身已经非常强大,但它们非常有限。除了仅限于类型的自然限制(这在 C++ 模板中并不存在)之外,我们已经看到每个实例仅被视为 object
。这就是泛型约束概念的由来。
通过设置约束,可以克服仅限于非常通用选项的限制。约束以 where
关键字开头。让我们看一个非常简单的约束:
class MyGenericFour<T> where T : Random
{
//In here, we have all options of the Random class on T
}
这个泛型类接受所有可以转换为 Random
类的类型,即 Random
类或其派生类。约束也可以引用类型参数,如下例所示:
class MyGenericFive<T, V> where T : List<V>
{
//In here, we have all options of the List generic class
}
组合也是可能的。对多个类型的约束始终用 where
关键字分隔。让我们看一个组合示例:
class MyGenericSix<T, V> where T : List<V> where V : Random
{
//In here, we have all options of the List<Random> generic class
}
还有其他几种可能性,例如,new()
表示给定类型必须具有默认构造函数。如果我们不指定此项,则无法创建类型参数类型的实例。让我们直接来看一个泛型方法:
T CreateNewRight<T>() where T : new()
{
return new T();//Works
}
T CreateNewWrong<T>()
{
//Does not work
//return new T();
//But this works always
return default(T);
}
如果我们没有在类型参数上添加 new()
约束,C# 将不允许我们使用默认构造函数。但是,我们始终可以使用 C# 的 default()
方法。它将始终为引用类型返回 null
,为值类型返回默认值(例如,int
为零或 bool
为 false
)。
对一个类型还可以有更多的约束。在这种情况下,我们用逗号分隔多个约束,从最强的限制开始,以最弱的限制结束。让我们看一个简短的例子:
T CreateStopwatch<T>() where T : Stopwatch, new()
{
return new T();
}
在此示例中,T
必须是 Stopwatch
(或其派生类),但是,它不能是此类派生类,因为它没有空的默认构造函数。
Lambda 表达式
在本教程前面,我们介绍了委托类型,它是一种托管函数指针。现在,我们将需要一个委托来提供对一个无名方法的引用。无名方法称为匿名方法。可以使用 delegate
关键字创建此类匿名方法,但我们不会以这种方式进行介绍。相反,我们将介绍一种更简单(如今更常用)的创建方法,即使用 fat arrow operator =>
。此运算符在左侧的参数和右侧的函数之间创建关系。让我们看一些示例:
x => x * x;
(x, y) => x * x + y;
() => 42;
() => Console.WriteLine("No arguments given");
所有这些语句都将是正确的 Lambda 表达式,但是,如果没有指定 Lambda 表达式的引用应该存储在哪里,编译器将不允许我们这样做。这些语句单独无法编译的另一个原因是,我们仍然是强类型的,并且没有指定参数类型。我们很快就会纠正这些问题。现在,让我们看看这些示例中的主要要点:
- 如果我们只有一个参数,则不需要用圆括号括起来。
- 多个(或零个)参数需要圆括号。
- 右侧自动返回该值。
- 如果右侧的值是
void
,则不返回任何内容。
右侧还可以包含多个用大括号括起来的语句。如果使用大括号,则需要使用 return
关键字,就像在普通方法中一样。让我们看几个例子:
x =>
{
return x > 0 ? -1 : (x < 0 ? 1 : 0);
};
() =>
{
Console.WriteLine("Current time: " + DateTime.Now.ToShortTimeString());
Console.WriteLine("Current date: " + DateTime.Now.ToShortDateString());
};
() =>
{
Console.WriteLine("The answer has been generated.");
return 42;
};
好的,那么我们还需要做的就是为这些 Lambda 表达式指定一个类型。让我们回到我们的第一轮示例:
Func<double, double> squ = x => x * x;
Func<double, double, double> squoff = (x, y) => x * x + y;
Func<int> magic = () => 42;
Action noarg = () => Console.WriteLine("No arguments given");
这现在将编译,并且它将完成我们需要的所有工作。
我们之所以引入 Lambda 表达式而不是用于匿名方法的基于 delegate
的语法,还有另一个原因。首先,Lambda 表达式可以编译成一种称为 Expression
的特殊类型。我们不会详细介绍,但这是 Lambda 表达式的一个非常方便的特性。另一个原因是,在 Lambda 表达式中捕获局部作用域变量感觉非常自然。然后,Lambda 表达式称为闭包。请考虑以下示例:
class MyClass
{
public void WriteCount(int min, int max)
{
int current = min;
Action wc = () => Console.WriteLine(current++);
while (current != max)
wc();
}
}
在此示例中,我们创建了一个名为 current
的局部作用域变量(此外,我们还有来自参数的局部作用域变量 min
和 max
)。在变量 wc
中引用的 Lambda 表达式不接受任何参数,返回 void
,但是,它使用了局部作用域变量 current
。因此,它捕获了局部作用域变量。此外,它还通过使用递增运算符来更改它。
匿名对象和类型推断
Lambda 表达式已经很酷了,但 C# 编译器还能做更多。创建匿名方法后,我们还可以继续创建匿名类实例!这些匿名对象在我们只想临时组合元素但又懒得创建类定义,或者需要某些功能(如不变性或已定义的相等运算符)时非常方便。让我们看看这里的含义:
new { Name = "Florian", Age = 28 };
这个简短的代码片段已经创建了一个包含两个属性的匿名对象,一个名为 Name
,包含一个读取“Florian
”的 string
,另一个名为 Age
,包含一个值为 28
的整数。但是,我们现在面临一个与 Lambda 表达式类似(但更严重)的问题。我们需要将此匿名实例分配给一个变量,否则我们会收到编译错误。将此值分配给变量需要我们(因为我们是强类型的)指定一个类型。现在应该使用什么类型?让我们考虑以下示例:
object anonymous = new { Name = "Florian", Age = 28 };
这将起作用,但我们会发现使用 Name
和 Age
属性是不允许的。因此,即使这会编译,对我们的目的来说它也不会非常有价值。这时 C# 就派上用场了!C# 编译器可以使用 var
关键字推断各种类型。以下是一些示例:
var myint = 3; //The type will be int
var mydouble = 3.0; //The type will be double
var mychar = '3'; //The type will be char
var mystring = "3"; //The type will be string
重要的是要知道编译器实际上会解析这一点并推断正确的类型。这与动态编程无关,并且 var
在 C# 中绝对不同于 JavaScript 或其他动态语言。
现在有了类型推断,我们可以再次尝试创建一个匿名方法:
var anonymous = new { Name = "Florian", Age = 28 };
//This works now:
Console.WriteLine(anonymous.Name);
如果我们悬停在 var
关键字上,我们将看到编译器推断出的类型。在匿名方法的情况下,我们总是会得到一些实际上告诉我们正在使用匿名类型的内容。var
关键字仅限于局部变量,不能用于全局变量或参数类型。因此,我们仍然面临使用匿名类型的一个大缺点:只能在定义它的局部方法中访问匿名类型的成员。因此,传递匿名对象只能作为 object
。
关于匿名对象还有其他有趣的事情。第一:每个匿名对象都有一个专门版本的 ToString
方法。它不会像往常一样只返回一个告诉我们这是一个匿名类型的 string
,而是返回一个包含所有属性及其值的 string
。另一个有趣的事情是 Equals
和 GetHashcode
也得到了专门化,使任何匿名对象都成为一个理想的对象来与其他对象进行比较。最后,所有属性都是只读的,从而产生一个不可变对象。
扩展方法
C# 编译器提供的另一个很棒的功能是扩展方法。它们基本上解决了一个相当常见的问题:假设我们获得了一组已有的类型,并且我们想用一组非常有用的方法来扩展(其中一些)类型,我们唯一能做的就是创建一些 static
方法在另一个类中。让我们看一些基于某些 .NET 内部类型的示例:
public static class MyExtensions
{
public static void Print(string s)
{
Console.WriteLine(s);
}
public static int DigitSum(int number)
{
var str = number.ToString();
var sum = 0;
for (var i = 0; i < str.Length; i++)
sum += int.Parse(str[i].ToString());
return sum;
}
}
现在,使用这些方法的唯一方法是间接使用的,如下面的代码片段所示:
MyExtensions.Print("Hi there");
var sum = MyExtensions.DigitSum(251);
然而,从面向对象的角度来看,这是错误的。实际上,我们想说的是:
"Hi there".Print();
var sum = 251.DigitSum();
这种方式不仅更短,而且更精确地表示了我们的意思。直到 C# 3,才有可能使用此类外部方法扩展现有类型,但是,使用参数列表中的 this
关键字可以实现这一点。让我们重写我们最初的代码:
public static class MyExtensions
{
public static void Print(this string s)
{
Console.WriteLine(s);
}
public static int DigitSum(this int number)
{
var str = number.ToString();
var sum = 0;
for (var i = 0; i < str.Length; i++)
sum += int.Parse(str[i].ToString());
return sum;
}
}
没有太大变化……如果我们仔细观察,最终会发现两个方法的签名发生了变化。此外,我们现在在第一个参数的类型之前指定了 this
关键字。虽然我们使用这两种方法的第一种方式仍然有效,但我们将获得第二种方式的访问权限,前提是满足以下先决条件:
- 我们需要引用定义这些方法的库(如果它们在同一个项目中定义,则没有问题)。
- 我们需要位于扩展方法所在的命名空间中,或者通过
using
指令包含扩展方法的命名空间。 - 我们需要使用已指定的(派生或基)类型的实例。
如果满足所有这些要求,那么我们就可以继续了。在其签名中使用 this
关键字的方法称为扩展方法,在原始 VS IntelliSense 列表中,它们将使用一个额外的蓝色向下箭头图标来表示。
LINQ
到目前为止,我们辛勤工作才达到了可以使用所有这些给定技术的地步。语言集成查询(Linq、LinQ 或 LINQ)是一种语言/框架技术,其中所有这些特性都非常有用。我们将看到:
- Lambda 表达式将在每一步中都必需,
- 创建小的临时匿名类型包将非常有益,
- LINQ 表达式本身就是(泛型的)扩展方法,并且
- 类型推断将使我们的(编程)生活极其轻松。
那么 LINQ 是什么?LINQ 是一组非常有用的扩展方法,位于 System.Linq
命名空间中。由于这些扩展方法非常有用,因此每个 C# 标准模板都包含所需的命名空间是很自然的。每个 IEnumerable<T>
实例(即每个数组、列表或实现 IEnumerable
接口的类)都具有 LINQ 方法。LINQ 的主要目的是运行(大型)数据集的查询并减少所需代码量。
让我们看一个非常简单的例子:我们有一个包含整数的数组,其中可能包含重复项。现在我们希望删除这些重复项:
//Create an array and directly initialize the values
int[] myarray = new int[] { 1, 2, 3, 4, 4, 5, 2, 9, 11, 1 };
//Use LINQ
myarray = myarray.Distinct().ToArray();
我们只用一行代码就将数组的条目从 10 个减少到 7 个,删除了所有重复项。我们所要做的就是调用 Distinct
扩展方法。这给了我们一个 IEnumerable<int>
实例,它使用 ToArray
扩展方法保存为 int[]
。
让我们看另一个例子:使用 LINQ 对 double
列表进行排序!
//Create a list and directly initialize
var a = new List<double>(new [] { 3.0, 1.0, 5.5, -1.0, 9.0, 2.5, 3.1, 1.1, 0.2, -5.2, 10.0 });
//Set up the query
var query = a.OrderBy(m => m);
a.Add(0.0);
//Run query and save in the variable a
a = query.ToList();
这里发生了什么?首先,这看起来与之前一样,只是我们将查询暂时保存在一个名为 query
的变量中。然后我们稍后使用这个变量通过 ToList
扩展方法创建列表。到目前为止没什么特别的。但是,如果我们仔细查看结果,我们会发现它包含值 0
。当我们设置查询时,此值不在列表中。这怎么可能?答案在于 LINQ 实际上从未执行查询,除非有人请求结果。结果可以通过迭代元素来请求,这在将结果存储在数组或列表中时是隐含的。LINQ 的此功能称为延迟执行。
另一个(更具说明性的)例子是仅获取 LINQ 查询中的偶数元素:
var elements = new List<int>(new int[] { 1, 3, 5, 7 });
var query = elements.Where(m => m % 2 == 0);
elements.Add(4);
elements.Add(6);
var firstEven = query.First();
var lastEven = query.Last();
elements.Add(2);
var newLastEven = query.Last();
尽管我们在设置查询时列表中没有偶数元素,但一旦我们请求结果,我们就会得到最新的结果。First
或 Last
也请求结果。这些类型的 First
或 Last
方法(还有 ElementAt
等)仅在给定集合不为空时才起作用。因此,跳过 Add
方法的调用将导致异常。有多种方法可以避免此异常。显然,我们可以使用 try
-catch
块,但这太多了。我们也可以只使用提供的 FirstOrDefault
(或 LastOrDefault
等)方法,该方法不会导致异常,而是仅返回 default(T)
值,对于整数类型为 0
,对于任何引用类型为 null
。
最优雅的解决方案(也是最通用的解决方案)无疑是运行一个简短的评估,以确定查询是否包含任何元素。这是使用 Any
扩展方法完成的。
var elements = new List<int>(new int[] { 1, 3, 5, 7 });
var query = elements.Where(m => m % 2 == 0);
//Try uncommenting the following line to see the difference
//elements.Add(2);
if (query.Any())
{
var firstEven = query.First();
var lastEven = query.Last();
}
else
{
Console.WriteLine("The query cannot run ...");
}
这是我们需要对 LINQ 更加敏感的部分。显然,LINQ 非常方便,因为它将为我们节省大量代码行。它是如何工作的?好吧,LINQ 构建在迭代器之上,这些迭代器可以与 foreach
循环结合使用。此循环比 for
循环更昂贵(需要多一次操作,即获取迭代器,然后在每一步都需要调用 Next
方法)。因此,LINQ 语句自然会带有一些开销。同样显而易见的是,LINQ 无法考虑某些数据结构可能实现的性能优化。
另外,正如我们所见,LINQ 语句是延迟的,即它们仅在请求结果(或部分结果)时才执行。如果我们的 dataset
发生更改,LINQ 结果也会随之更改。因此,黄金法则是:如果我们想在某个(代码)时间点获得结果,那么我们需要执行 ToArray
、ToList
调用,或迭代查询。LINQ 扩展了任何 IEnumerable<T>
。任何 IEnumerable<T>
都是所谓的内存内查询,而任何 IQueryable<T>
都是所谓的远程数据查询。对我们来说,区别不大,因为在本教程中我们将只使用内存内查询。通常,在数据访问层 (DAL) 工作时,人们会非常在意。将 LINQ 用于数据库查询简单明了,并且会产生更少出错的代码。将 LINQ 用于编写数据库查询的最大缺点是,某些 LINQ 语句将(完全或以与以前相同的方式)不起作用。
到目前为止,我们主要将 Lambda 表达式用于我们的 LINQ 查询。在下一个示例中,我们还将使用匿名对象:
var powers = Enumerable.Range(0, 100).Select(m => new {
Number = m,
Square = m * m,
Cube = m * m * m
}).ToArray();
首先,我们使用 Range
扩展方法提供一个从 0 开始的 100 个整数的枚举。然后我们从每个元素中选择一个新的匿名对象。Select
扩展方法允许我们使用当前给定的元素来设置从此时开始应该呈现哪个元素。此 LINQ 语句称为投影,因为它将当前给定的元素投影到一组新元素。虽然它看起来与 SQL 中的 select
语句非常相似,但它略有不同。如果我们不指定它,我们将只获得原始元素。此外,我们不是用它来指定我们想要的列,而是指定我们想要获得的属性或变量的类型。最后,我们还可以使用它来调用方法和创建新对象。
LINQ 的一个重要部分是所谓的语言扩展。LINQ 有两种风格:
- 一组扩展方法,用作普通方法
- C# 中的一个子语言,看起来很像 SQL
到目前为止,我们只看了第一部分,然而,第二部分也应该讨论一下。
用于 LINQ 查询的子语言由一组新关键字定义,如 where
、select
、from
或 let
。用子语言编写的查询称为查询表达式。另一方面,使用扩展方法被称为使用流畅语法。让我们先看一个查询表达式定义的示例:
var names = new[] { "Tom", "Dick", "Harry", "Joe", "Mary" };
var query = from m in names
where m.Contains("a")
orderby m.Length
select m.ToUpper();
每个查询表达式都以 from
关键字开头,以 select
或 group
子句结尾。中间,我们可以做任何我们想做的事情。接下来的图形显示了所有可能的关键字及其允许顺序的概述。
如果我们比较查询表达式和流畅语法,我们可以看到翻译非常简单:
var names = new[] { "Tom", "Dick", "Harry", "Joe", "Mary" };
var query = names
.Where(m => m.Contains("a"))
.OrderBy(m => m.Length)
.Select(m => m.ToUpper());
现在我们可以实际看到 from
语句做了两件事:
- 它启用了查询表达式。
- 它为元素(参数)设置名称,以便在查询中使用。
因此,查询表达式语法的最大优点是我们不必每次都重新指定元素名称(省略示例中的 m =>
语句)。
另一个好处无法通过上面的示例看到。已经提到的一件事是,查询表达式语法还引入了 let
关键字。熟悉 F# 或某些函数式语言的人都知道 let
经常用于分配一个新的(局部)变量。
让我们看一个简短的例子,它扩展了上面的代码:
var names = new[] { "Tom", "Dick", "Harry", "Joe", "Mary" };
var query = from m in names
let vowelless = Regex.Replace(m, "[aeiou]", string.Empty)
where vowelless.Length > 2
orderby vowelless
select m + " -> " + vowelless;
这里,vowelless
是每个元素的局部变量。应该清楚的是,编译器通过使用 Select
方法并结合一个新的匿名对象来实现这一点。但是,我们永远看不到这种结构,并且有一个很好的声明性语句。
为了完成这次讨论,我们需要确保对查询表达式和流畅语法都有正确的判断。
使用流畅语法有一些优点:
- 它不限于
Where
、Select
、SelectMany
、OrderBy
、ThenBy
、OrderByDescending
、ThenByDescending
、GroupBy
、Join
和GroupJoin
(例如,Min
仅在流畅语法中可用)。 - 它可以轻松扩展。
- 方法将被调用以及调用的顺序是直接可见的。
另一方面,使用查询表达式语法也有一些优点:
- 它允许我们使用
let
关键字引入新变量。 - 它大大简化了具有多个生成器然后引用外部范围变量的查询。
Join
或GroupJoin
查询也将变得更加简单。
这里的底线是什么?大多数人倾向于使用流畅语法,因为它功能更强大,对于从未见过 LINQ 查询的人来说更容易理解。由于两种方式可以混合使用(使用查询表达式进行一般查询,并为查询表达式语法中不存在的子部分使用流畅语法),因此无法提出建议。最终,这将取决于个人偏好。
Windows Forms 开发
总的来说,有几种创建图形用户界面 (GUI) 的可能性,对于 Microsoft Windows 也有很多。一些用于创建 GUI 的框架甚至旨在实现跨平台。我们现在将稍微了解一下 Windows Forms,它是 C# 和 .NET 框架的第一个 GUI 框架。Windows Forms 也是面向对象代码的一个很好的例子,它具有清晰的类层次结构和易于学习的理念。
要在 Visual Studio 中创建新的 Windows Forms 项目,我们只需选择 **文件** 菜单,然后选择“**新建**、**项目**、**C#**、**Windows Forms 应用程序**”。就是这样!此项目类型的模板将自动执行一些不错的操作,例如创建新的主窗口或在 Main
方法中启动它。
我们首先注意到的将是打开了一种新编辑器。这就是所谓的“设计器”。我们不是通过编写代码来放置各种组件,而是在一个专用编辑器中放置控件、设置属性并进行基本设计。在幕后,此设计器会为我们编写代码。
屏幕截图显示了设计器,左侧是工具箱,右侧是属性窗口。可以从工具箱将控件拖放到设计器中打开的任何窗体上。一旦选中了一个控件(包括窗体本身),我们就可以在属性窗口中更改其属性。
设计器使用一种称为部分类的功能。partial
关键字告诉编译器一个类的定义被拆分到多个文件中。在我们的例子中,VS 将为每个窗体生成两个(代码)文件:
- 一个用于我们的代码的文件,以 *.cs 结尾(如 Form1.cs)
- 一个用于设计器的文件,以 *.Designer.cs 结尾(如 Form1.Designer.cs)
如果我们看一下我们的代码文件,它将与以下代码非常相似:
using System;
using System.Drawing;
using System.Windows.Forms;
namespace MyFirstForm
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
}
}
虽然 System
命名空间应该始终包含,但我们需要 System.Windows.Forms
命名空间才能使用 Form
而不必显式编写命名空间。另一方面,System.Drawing
命名空间在此代码片段中未使用,但通常对 Windows Forms 开发非常有用。
那么这段代码有什么特别之处?第一件要注意的事情是 partial
关键字在类定义中的使用。第二件事是显式放置了一个默认构造函数。这是调用 InitializeComponent
方法所必需的。这不是继承的方法,显然也不是在代码片段中定义的。它在哪里定义的,它做了什么?
InitializeComponent
方法负责使用设计器的输入。设计器所做的一切都放在另一个代码文件中。此文件看起来类似于以下代码片段:
namespace MyFirstForm
{
partial class Form1
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
components.Dispose();
base.Dispose(disposing);
}
private void InitializeComponent()
{
this.button1 = new System.Windows.Forms.Button();
this.SuspendLayout();
//
// button1
//
this.button1.Location = new System.Drawing.Point(84, 95);
this.button1.Name = "button1";
this.button1.Size = new System.Drawing.Size(75, 23);
this.button1.TabIndex = 0;
this.button1.Text = "button1";
this.button1.UseVisualStyleBackColor = true;
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(284, 262);
this.Controls.Add(this.button1);
this.Name = "Form1";
this.Text = "Form1";
this.ResumeLayout(false);
}
private System.Windows.Forms.Button button1;
}
}
在此示例中,我们已经在窗体上放置了一个 Button
控件。在这里,我们可以学到以下几点:
- 设计器使用非常明确的代码,不使用任何命名空间。
- 设计器始终使用
this
来引用全局变量。 - 设计器定义了
InitializeComponent
方法。 - 组件的所有变量都放在设计器文件中。
- 所有组件的实例都在
InitializeComponent
方法中初始化。
因此,构造函数中的任何修改都应发生在 InitializeComponent
方法调用之后。这一点很重要,可以避免涉及 null
引用异常的任何错误,因为如果我们尝试访问由设计器初始化的变量,可能会发生这种情况。
现在我们对设计器及其工作方式有了大致了解,我们需要更仔细地研究 Windows Forms UI 框架。我们之所以研究这个特定的 UI 框架,是因为它是面向对象设计的绝佳示例。尽管这对几乎所有 UI 框架都适用,但其中一些框架使得它不容易被轻易看到。此外,其他 UI 框架的学习曲线可能更陡峭。
显然,Windows Forms 背后有一个庞大的 OOP 树。对我们来说,最重要的类是 Control
。几乎所有控件都直接或间接从中派生。一个非常重要的派生类是 Form
,它表示一个窗口。每个 Control
都可以在控件列表中绘制或托管。
让我们看看如何放置我们自己的控件而不使用设计器。下面的代码片段是前面示例中的构造函数:
public Form1()
{
InitializeComponent();
//Create a new instance
var myLabel = new Label();
//Set some properties
myLabel.Text = "Some example text";
myLabel.Location = new Point(100, 60);
myLabel.ForeColor = Color.Red;
//Without the following line, we would see nothing
this.Controls.Add(myLabel);
}
我们所要做的就是应用我们的知识。我们创建一个类的新实例(在本例中,我们使用 Label
,它基本上是一个文本标签),将某些属性更改为所需值,然后将其添加到使用它的某个对象的集合中。在这种情况下,将其添加到当前窗体的 Controls
集合中,将直接在窗体中托管新控件。我们也可以将控件托管在其他控件中。
此托管将完成控件的绘制和逻辑。否则,如果我们不将控件托管在某个地方,那么什么也不会发生。
这种魔术(绘制和逻辑)必须从某个地方开始。它当然从 Form
开始,但是应用程序如何知道要选择哪个 Form
以及如何构建它?看一下 Program.cs 会发现代码与以下内容非常相似:
using System;
using System.Windows.Forms;
namespace MyFirstForm
{
static class Program
{
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}
}
目前,我们不关心 [STAThread]
属性。简而言之:此属性本质上是 Windows 消息泵与 COM 组件通信的要求。消息泵(或消息循环)通过静态 Application
类中的 Run
方法启动。我们的消息泵与 Form1
的实例一起运行,即如果我们关闭此(主)窗口,则应用程序将关闭。
该循环实际上集成在 Windows 中。一旦调用 Run
方法,Windows 就会注册我们的应用程序并将消息发布到队列。这些消息以事件的形式到达我们的 Form
实例。此类事件可以是鼠标悬停、鼠标单击、键盘按下或其他事件。大多数控件的状态基于此类事件(如悬停)。
下一个图形显示了各种组件如何相互交互。虽然绿色字段是外部输入,但紫色字段由操作系统(例如 Windows)控制。Windows 控制消息队列或启动重绘/更新过程。蓝色字段是框架的作用,而红色字段可以是我们的事件处理程序或我们为处理事件而编写的任何代码。
我们也可以注册自己的处理程序,但目前,通过设计器完成已经足够了。通过双击控件(将创建默认事件的处理程序,例如 Button
控件的点击事件)或在属性视图中打开带有闪电符号的选项卡,可以在设计器中完成此操作。双击所需的事件将创建事件处理程序。
事件处理程序只不过是一个方法,当事件触发时将被调用。因此,如果用户单击我们窗口上的 Button
控件并且我们为此控件指定了事件处理程序,那么将调用指定的该方法。
通常,这看起来会与此类似:
private void button1_Click(object sender, EventArgs e)
{
//Write code here, like...
MessageBox.Show("The button has been clicked!");
}
Windows Forms 框架的面向对象设计允许我们轻松创建自己的控件。我们所需要做的就是从合适的基类派生,如 Control
或更专业的类。如果我们想创建一个会闪烁的标签,我们可以这样做:
//Here, we directly inherit from Label, which derives from Control
public class BlinkLabel : Label
{
Timer timer;
public BlinkLabel()
{
timer = new Timer();
//Set the timer to an interval of 1s
timer.Interval = 1000;
//This will be explained in the next tutorial
timer.Tick += DoBlink;
}
//This is the event handler for the tick event
void DoBlink(object sender, EventArgs e)
{
//Just switch the Visible state
Visible = !Visible;
}
}
编译后,我们将在工具箱中找到我们自己的 BlinkLabel
控件,所有控件都放在那里。这意味着我们可以轻松地将 BlinkLabel
的实例拖放到任何 Form
实例上,该实例可以由设计器修改。
我们发现的另一种可能性是重写某些给定的方法。让我们看下面的代码:
public class Ellipse : Control
{
public Ellipse()
{
}
protected override OnPaint(PaintEventArgs e)
{
base.OnPaint(e);
/* Implementation in the next section */
}
}
在这里,我们使用几种方法之一来包含我们自己的绘图逻辑。所采用的方法是重写 OnPaint
方法,该方法为我们提供一个类型为 PaintEventArgs
的参数。在这里,我们主要对 ClipRectangle
和 Graphics
属性感兴趣。第一个属性提供了边界(以 Rectangle
实例的形式),而 Graphics
是所谓的图形指针。
另一种方法是为 Paint
事件创建事件处理程序。这种方法仅应与控件实例一起使用。一些控件(例如 ListBox
)为我们提供了指定绘图逻辑的第三种方法。这种第三种方法意味着设置例如 ListBox
实例的 DrawMode
属性。最后,我们必须为 ListBox
案例创建例如 DrawItem
的事件处理程序。这种第三种方法使我们能够绘制控件的特定部分,例如 ListBox
案例中的包含项。
我们现在将更仔细地研究 Windows Forms 中的绘图。
Windows Forms 中的自定义绘制
Windows 的(2D)绘图 API 称为 GDI(图形设备接口)。GDI 相对于直接访问硬件的方法,其最显著的优势可能是其缩放能力以及对目标设备的抽象表示。使用 GDI,可以轻松地在多个设备(如屏幕和打印机)上绘图,并期望在每种情况下都能正确再现。
Windows Forms UI 框架将此 API 扩展到 GDI+,这将为我们提供一个 GDI 之上的面向对象层。该层还包含一组非常有用的辅助方法。虽然 GDI 只包含一组非常基本的像素操作函数,但 GDI+ 已经具有绘制椭圆、矩形或复杂路径的方法。
使用 GDI+ 的中心对象称为 Graphics
。我们不能直接使用 new
来构造它,而是通过 Graphics
的 static
方法间接构造。调用此类方法需要一个参数,例如图像或屏幕。图像具有优势(例如,缓冲、修改等),但它们会消耗大量内存。创建图像非常容易:
var bmp = new Bitmap(400, 300);
Bitmap
类是 abstract
基类 Image
的一个实现。如果我们现在想使用 Graphics
对象,我们只需编写以下语句:
var g = Graphics.FromImage(bmp);
这将使用给定的 Bitmap
实例创建一个新的 Graphics
对象。现在我们可以使用以 Draw
和 Fill
开头的方法。这些方法绘制边框或填充给定形状的内容。绘图需要 Pen
类的实例,该类定义了任何边框的样式。
填充是通过派生自基类 Brush
的类的实例完成的。一个非常基本的实现由 SolidBrush
提供,它用统一的颜色填充路径。更高级的实现由 LinearGradientBrush
(生成颜色渐变)或 TextureBrush
(使用 Image
作为纹理)提供。
现在让我们看一个使用 GDI+ 绘图的方法。该方法将创建一个 400 像素(宽)x 300 像素(高)的位图,其中包含一些矩形和椭圆:
Bitmap DrawSimpleRectangle()
{
//Create the bitmap
Bitmap bmp = new Bitmap(400, 300);
//Get the graphics context for the bitmap
Graphics g = Graphics.FromImage(bmp);
//The smoothing mode enables drawing of intermediate pixels
g.SmoothingMode = SmoothingMode.AntiAlias;
//Draw a simple rectangle (fill the complete rectangle with yellow)
g.FillRectangle(Brushes.Yellow, new Rectangle(0, 0, 400, 300));
//Drawing a rectangle with some big border
g.DrawRectangle(new Pen(Color.Red, 4f), new Rectangle(10, 10, 380, 280));
//Let's create another rectangle for our circle
//(circle is a special ellipse with width = height)
var circle = new Rectangle(15, 15, 270, 270);
//Drawing a very simple linear gradient requires using a LinearGradientBrush object
var lgb = new LinearGradientBrush(new Point(15, 15),
new Point(295, 295), Color.Red, Color.Black);
//Now we can fill an ellipse with the gradient brush
g.FillEllipse(lgb, circle);
//Let's just try another circle
g.DrawEllipse(Pens.DarkGreen, circle);
return bmp;
}
这真的只是调用一些方法并将正确的参数传递进去。通常,我们还需要创建许多不同的 Pen
、Brush
和 Color
实例,但幸运的是,我们可以使用 static
属性(如 Color
的 Red
或 Pens
的 DarkGreen
)提供的一些预定义对象。
在第一个示例之后,我们可以尝试绘制更炫的东西或开始动画。动画只是一系列不同的位图(称为帧),这些帧在帧之间存在一些差异。如果我们考虑一个旋转的矩形,我们会看到唯一的区别在于矩形的角度。
此时可能会出现的问题是:如何绘制具有不同角度的矩形?没有参数可以指定矩形的角度。这就是变换概念的由来。这个概念已经集成到 GDI+ 中,并且起着非常重要的作用。Graphics
的每个方法都使用当前设置的变换。
变换由一个 3 列 2 行的矩阵设置。该矩阵只是一个 2x2 矩阵的压缩(单个对象)形式,带有一个长度为 2 的附加向量。有三种变换可以改变矩阵的值:
- 平移(由向量或最后一列的条目给出)
- 旋转(由 2x2 矩阵的所有元素给出)
- 缩放(由矩阵的对角线元素给出)
我们不需要关心矩阵操作,因为我们已经有了 Graphics
对象提供的 TranslateTransform
、RotateTransform
或 ScaleTransform
等方法。它们会为我们做数学计算。
让我们看一个旋转给定矩形的示例:
Bitmap DrawTransformedRectangle()
{
//The same start as before
Bitmap bmp = new Bitmap(400, 300);
Graphics g = Graphics.FromImage(bmp);
g.SmoothingMode = SmoothingMode.AntiAlias;
//Fill some rectangle (full image)
g.FillRectangle(Brushes.Turquoise, new Rectangle(0, 0, 400, 300));
//Use the translate to change (px, py) to (px', py') by using px' = px + a, py' = py + b
g.TranslateTransform(200, 150);//a = 200, b = 150
//Use rotate to change px', py' to px'', py'' by using cos(alpha) * px' -
//sin(alpha) * py', cos(alpha) * px' + sin(alpha) * py'
g.RotateTransform(45f);//Here: alpha in degrees!
//Scale it with px'', py'' to px''', py''' by using a * px'', b * py''
g.ScaleTransform(0.3f, 0.3f);//a = 0.3, b = 0.3
//Draw a rectangle using these transformations
g.DrawRectangle(Pens.Red, new Rectangle(-100, -50, 200, 100));
return bmp;
}
变换非常有用,因为它们帮助我们缩放、旋转和平移图像(部分)而无需进行大量数学计算。魔术发生在后台,当使用矩阵计算像素位置时。
在 Windows Forms 框架中绘图的另一个方便之处是路径的概念。路径是点的列表。当最后一个(最终)点连接到第一个(初始)点时,我们说路径是闭合的。闭合路径会创建一个区域。
我们可以对区域应用运算符(如并集),从而产生有趣的区域,这些区域可以被填充或绘制。让我们看看我们如何创建一个非常简单的任意路径:
Bitmap DrawArbitraryPath()
{
//Same start again
Bitmap bmp = new Bitmap(400, 300);
Graphics g = Graphics.FromImage(bmp);
//Create the path, i.e., a new `GraphicsPath` object
GraphicsPath p = new GraphicsPath();
//Add some fixed lines by adding the points
p.AddLines(new Point[]
{
new Point(200, 0),
new Point(220, 130),
new Point(400, 130),
new Point(220, 150),
new Point(300, 300),
new Point(200, 150)
});
//Fill the path (this will fill one (the right) half)
g.FillPath(Brushes.Red, p);
//Scale with -1, i.e., 200 will be -200, 220 will be -220
g.ScaleTransform(-1f, 1f);
//Transform with -400, i.e., -200 will be 200, -220 will be 180
g.TranslateTransform(-400, 0);
//Fill the second half (left half)
g.FillPath(Brushes.Blue, p);
return bmp;
}
这个例子提供了一个带有蓝色和红色半部分的星形图像。
最后,我们有足够的信息来完成我们的 Ellipse
控件。我们将以一种可以播放动画的方式制作此控件。
public class Ellipse : Control
{
float thickness;
float angle;
Color fillColor;
Color borderColor;
public Ellipse()
{
borderColor = Color.Black;
fillColor = Color.White;
thickness = 2f;
}
public Color FillColor
{
get { return fillColor; }
set { fillColor = value; Refresh(); }
}
public Color BorderColor
{
get { return borderColor; }
set { borderColor = value; Refresh(); }
}
public float Angle
{
get { return angle; }
set { angle = value; Refresh(); }
}
public float Thickness
{
get { return thickness; }
set { thickness = value; Refresh(); }
}
protected override void OnPaint(PaintEventArgs e)
{
//Introduce g as a shorthand for e.Graphics
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
//We want a circle, i.e., min(width, height)
var w = Math.Min(Width, Height) / 2;
//Let's create a rectangle for the circle
var circle = new Rectangle(-w, -w, 2 * w, 2 * w);
//We will need that pen more often
var pen = new Pen(borderColor, thickness);
//Go into the center of our circle
g.TranslateTransform(Width / 2, Height / 2);
//And rotate
g.RotateTransform(angle);
//Now fill and draw the circle
g.FillEllipse(new SolidBrush(fillColor), circle);
g.DrawEllipse(pen, circle);
//And then draw the line such that we see the angle
g.DrawLine(pen, new Point(0, 0), new Point(0, -w));
}
}
编译后,我们可以在设计器中使用我们自己的控件。工具箱包含 Ellipse
控件。我们可以将其拖放到窗体上。这看起来如下面的屏幕截图所示:
更改角度属性将导致围绕圆心旋转。
Outlook(展望)
本教程系列的第二部分到此结束。在下一部分中,我们将介绍异步模型、C# 和 DLR 的动态编程,以及多线程、任务并行库 (TPL) 和反射。我们还将继续使用 Windows Forms 编写 GUI,并学习如何创建自己的事件或在没有设计器的情况下添加事件处理程序。
Other Articles in this Series(本系列其他文章)
- Lecture Notes Part 1 of 4 - An Advanced Introduction to C#(讲义 第一部分 - C# 高级入门)
- Lecture Notes Part 2 of 4 - Mastering C#(讲义 第二部分 - C# 精通)
- Lecture Notes Part 3 of 4 - Advanced Programming with C#(讲义 第三部分 - C# 高级编程)
- Lecture Notes Part 4 of 4 - Professional Techniques for C#(讲义 第四部分 - C# 专业技术)
参考文献
历史
- v1.0.0 | 初始发布 | 2016 年 4 月 19 日
- v1.0.1 | 添加了文章列表 | 2016 年 4 月 20 日
- v1.0.2 | 刷新了文章列表 | 2016 年 4 月 21 日
- v1.0.3 | 更新了一些错别字 | 2016 年 4 月 22 日
- v1.1.0 | 更新了带锚点的结构 | 2016 年 4 月 25 日
- v1.1.1 | 添加了目录 | 2016 年 4 月 29 日
- v1.2.0 | 感谢 Christian Andritzky 指出泛型问题 | 2016 年 5 月 10 日