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

C# 高级入门 - 讲义 - 第 1 部分,共 4 部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (176投票s)

2016年4月18日

CPOL

40分钟阅读

viewsIcon

339523

在第一部分中,我们介绍了C#的基础概念,以及面向对象编程和.NET框架。

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

目录

本教程旨在简要而高级地介绍C#编程。理解本教程的前提是具备编程知识、C语言知识以及一些基础数学知识。一些C++或Java的基础知识可能会有所帮助,但不是必需的。

引言

这是C#系列教程的第一部分。在本部分中,我们将介绍该语言的基础概念及其输出——Microsoft中间语言(MSIL)。我们将探讨面向对象编程(OOP),以及C#如何使OOP在实践中尽可能高效地实现。

供进一步阅读,文末将提供参考列表。这些参考资料将对本教程中讨论的某些主题进行更深入的探讨。

在阅读本教程之前,请稍加注意:本教程的运作方式是定义一个可用性路径。在进行任何C#操作之前,本部分教程基本上是必需的。完成本教程后,每个人都应该能够使用基本的面向对象原则编写简单的C#程序。因此,更高级或好奇的读者可能会错过一些部分。例如,我们不会研究异常处理、.NET框架的激动人心的功能(如LINQ)以及更高级的语言特性(如泛型或lambda表达式)。在这里,我们的目标是为来自其他语言的人提供一个C#的入门介绍。

正确的开发环境

在开始做任何事情之前,我们应该考虑在哪里做。编写程序的一个合乎逻辑的选择是文本编辑器。对于C#,有一个更好的选择:使用Microsoft Visual Studio(VS)!VS社区版可用于开源项目,无需购买许可证。然而,还有更多专门为C#设计的选项。如果我们想要一个跨平台IDE,那么MonoDevelop是一个不错的选择。在Windows上,VS和MonoDevelop的免费替代品是SharpDevelop。还有更通用的选项,如Visual Studio Code(VS Code)、Sublime Text或GitHub的Atom(仅举几例)。通过OmniSharp,所有这些都可以变成强大的C# IDE。

A short look at the Visual Studio 2012 IDE

使用Visual Studio这样强大的IDE,将极大地帮助我们编写.NET框架程序。诸如IntelliSense(智能自动完成功能,用于显示特定代码位置可能的函数调用、变量和可用关键字)、断点(可以在所需代码位置暂停并检查程序执行)以及用于UI开发的图形设计器等功能,将极大地帮助我们高效编程。

此外,Visual Studio还提供了集成的版本控制、对象浏览器和文档工具。使用Visual Studio编写C#项目的另一个好处是解决方案和项目的概念。在Visual Studio中,通常会为开发项目创建一个解决方案。一个解决方案文件可以包含多个项目,这些项目可以编译成库(*.dll)和可执行文件(*.exe)。解决方案资源管理器使我们能够有效地管理大规模项目。

MSBuild应用程序使用项目文件来编译所有必需的文件并链接到库或.NET框架等依赖项。我们作为开发人员,不再需要担心编写Makefile。相反,我们只需将文件和引用(依赖项)添加到项目中,它们就会自动按正确的顺序编译和链接。

有几个快捷方式/功能使VS成为真正的享受

  • Ctrl + 空格键 强制打开IntelliSense
  • Ctrl + . 当VS显示一个选项点时打开菜单(当缺少命名空间或重命名变量时会发生这种情况)
  • F5 构建并开始调试
  • Ctrl + F5 构建并执行(不调试)
  • F6 仅构建
  • F10 调试时,跳到当前函数内的下一行(单步跳过)
  • F11 调试时,跳到下一条或当前函数中的行(单步进入)
  • F12 转到插入符位置标识符的定义
  • Shift + F12 查找插入符位置标识符的所有引用
  • Ctrl + + 在整个项目中搜索给定符号

当然,这些键盘快捷键可以更改,并且不是必需的。通过快捷方式可以完成的所有操作都可以通过鼠标进行访问。另一方面,选项和可能性比快捷方式更多。

在哪里获取Visual Studio?有几种选择,其中一些甚至免费。参加DreamSparks/MSDNAA项目的大学生通常可以选择免费下载Visual Studio(最高级别)。否则,可以下载公共测试版或免费提供的社区版。以前,还有专门针对语言的Visual Studio版本,称为Express Edition。

免费提供的社区版可在VisualStudio.com上获取。对于个人开发者,Visual Studio社区版在开发任何类型的应用程序——无论是免费还是付费应用程序——都没有限制。对于组织,许可证仅限于在以下场景中使用该产品:

  • 在课堂学习环境中,
  • 用于学术研究,或
  • 为开源项目做出贡献。

在本教程中,我们将仅关注控制台应用程序。GUI将在下一个教程中介绍。要在VS中创建新的控制台应用程序项目,我们只需使用菜单文件:然后选择**新建**,**项目**。在对话框中,我们在左侧选择**C#**,然后在右侧选择**控制台应用程序**。最后,我们可以给项目起一个名字,如SampleConsoleApp。就是这样!我们已经创建了第一个C#应用程序。

基本概念

C#是一种托管的、静态强类型语言,具有类C的语法和类似Java的面向对象特性。总而言之,可以说C#在入门时与Java非常相似。当前版本的C#中有一些非常棒的功能,但在此第一个教程中,我们将排除它们。

托管意味着两件事:首先,我们不再需要关心内存。这意味着来自C或C++的人可以停止担心释放为对象分配的内存。我们只需创建对象并对其进行操作。一旦我们停止使用它们,一个名为垃圾收集器(GC)的智能程序就会负责它们。下图大致展示了垃圾收集器的工作原理。它会检测未引用的对象,收集它们并释放相应的内存。我们无法过多控制发生这种情况的时间。此外,GC会进行一些内存优化,但通常不会在释放内存后立即进行。

A simple approximation how the GC works

这会导致内存和性能方面的一些开销,但其优点是C#中不可能出现段错误。然而,如果我们保留不再需要的对象的引用,内存泄漏仍然是一个问题。

静态强类型语言意味着C#编译器需要知道每个变量的确切类型,并且类型系统必须是一致的。没有转换为void数据类型的说法,这基本上让我们做什么都可以,但我们有一个名为Object的顶层数据类型,这可能会导致类似的问题。稍后我们将讨论Object基类型的影响。强类型部分提示我们,操作只能在元素上定义的操作才能使用。没有未经我们知晓的显式类型转换。

C#托管的另一个后果是:C#不是本地的,也不是解释执行的——它是介于两者之间的一种。编译器不生成汇编代码,而是生成所谓的Microsoft中间语言(MSIL)。这种技巧节省了我们为不同平台重新编译的麻烦。对于C#,我们只需编译一次,即可获得所谓的公共语言运行时(CLR)程序集。该程序集将在运行时进行即时(JIT)编译。另一项功能是优化也将在运行时进行。经常调用的方法将被内联,不需要的语句将被自动省略。

理论上,这可能(取决于所使用的引用)导致平台无关的程序,但这意味着所有平台都有启动和JIT编译CLR程序集的要求。目前,Microsoft将.NET框架限制在Windows系列,但Xamarin提供了一个名为Mono的产品,它提供了一个也适用于Linux和Mac的解决方案。

回到语言本身,我们将看到面向对象的功能受到Java的启发。在C#中,只使用了一组略有不同的关键字。Java的extend关键字已被C++的冒号运算符:取代。在其他领域,冒号也用作具有不同(但相关)含义的运算符。

让我们来看一个示例代码:Hello Tech.Pro

//Those are the namespaces that are (by default) included
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

//By default, Visual Studio already creates a namespace
namespace HelloWorld
{
    //Remember: Everything (variables, functions) has to be encapsulated
    class Program
    {
        //The entry point is called Main (capital M) and has to be static
        static void Main(string[] args)
        {
            //Writing something to the console is easily possible using the
            //class Console with the static method Write or WriteLine
            Console.WriteLine("Hello Tech.Pro!");
        }
    }
}

这看起来与Java非常相似,除了大小写。注释可以通过使用斜杠(注释直到行尾,无法返回)或使用斜杠和星号字符来放置。在后一种情况下,注释必须以反向字符结束,即星号和斜杠。这种注释称为块注释。Visual Studio还有一种特殊的注释模式,在输入三个斜杠后触发。

回到代码:虽然我们不需要将类放在命名空间中,但Visual Studio默认会创建一个。然而,类的创建是必需的。每个方法和变量都需要封装。这就是为什么C#被认为是强面向对象的。数据封装是我们将在面向对象编程中看到的,因此在C#中是必需的。

我们可以从这个小样本中学到一些其他东西。首先,C#使用(也必须使用)一个名为Console的类来进行基于控制台的交互。这个类只包含所谓的静态成员,如WriteLine函数。静态成员是可以无需创建类实例即可访问的成员,即静态成员不能被复制。它们只存在一次,并且在没有显式创建的情况下存在。类的方法称为方法。

名为Main的入口点只能存在一次,因此它必须是static。另一个原因是它必须存在于一个类中。如果它不是静态的,那么类首先必须被创建(因为所有非静态成员只能通过创建称为类实例的对象来访问)。现在我们遇到了一个鸡生蛋还是蛋生鸡的问题。我们如何告诉程序构建类的一个实例(以便可以访问Main方法),而没有一个我们开始做任何事情的方法?因此,要求Main方法是static

我们还可以学到的是,参数args显然是一个数组。看起来数组用作数据类型,而在C中变量就是数组,类型是字符串。这是故意的,并且有一些有用的含义。我们稍后将看到,每个数组都基于一个名为继承的面向对象原理,该原理指定了一组基本的字段和实现。每个数组类型都有一个名为Length的字段,其中包含数组中的元素数量。这不再是隐藏的,并且需要作为附加参数传递,这就是为什么C#程序的标准Main方法只有1个参数,而C的标准main方法有2个参数。

命名空间

对于来自C语言的人来说,命名空间可能是一个新概念。命名空间试图为类型世界带来秩序。尽管C#允许我们拥有多个同名方法(称为方法重载),但其参数类型不同。因此,包含返回类型、输入类型和参数名称的完整签名不会绕过潜在的歧义,例如,当我们拥有具有相同输入类型但不同输出类型的函数时。在这种情况下,编译器无法区分不同的重载。

这种限制可能导致严重的问题。如果我们考虑使用(需要)两个(独立的)内部库的情况,那么这两个库都可能定义一个同名类型。现在将无法使用这两种类型(如果编译可能的话!)。充其量,我们可以使用一种类型。

这就是命名空间发挥作用的地方。命名空间就像一个容器,我们可以将类型放在其中。但是,该容器只是一个字符串,编译器将使用该字符串来区分类型。即使点(如a.b)通常用于在两个字符串(在本例中是ab)之间创建一种关系感,但没有规定在使用a.b之前必须先定义或使用命名空间a

通常,我们必须始终将该命名空间放在每个类型的前面,但是,使用C#的using关键字,我们告诉编译器为使用命名空间的所有类型隐式执行此操作。当前命名空间中的类型也由编译器隐式使用。只有一个例外,即如果我们想使用在多个使用的命名空间中定义的类型。在这种情况下,我们必须始终显式指定我们要使用的类型的命名空间。

数据类型和运算符

在我们实际开始做任何事情之前,我们需要介绍基本的数据类型和运算符。如前所述,C#是一种静态强类型语言,因此我们需要关注数据类型。最终,我们将不得不指定任何变量背后的类型。

有一组基本数据类型,其中包含以下类型:

  • bool(1字节)用于逻辑表达式(truefalse
  • char(2字节)用于单个(Unicode)字符
  • short(2字节)用于存储短整数
  • int(4字节)用于整数计算
  • long(8字节)用于长整数计算
  • float(4字节)用于单精度浮点数计算
  • double(8字节)用于双精度浮点数计算
  • decimal(16字节)用于定点精度浮点数计算

还有修改的数据类型,如无符号整数(uint)、无符号长整数(ulong)等。此外,还提供了一个功能强大的用于使用string的类。该类就叫string,它的工作方式正如预期的那样,除了许多有用的辅助功能可以直接使用。

单独的类型相当无聊,因为我们只能实例化它们,即根据它们的定义创建对象。一旦我们使用运算符将它们连接起来,事情就会变得更有趣。在C#中,我们拥有的运算符(及更多)与C相同:

  • 逻辑运算符,如==!=<<=>>=!&&||
  • 位运算符,如^&|<<>>
  • 算术运算符,如+-*/%++--
  • 赋值运算符=以及赋值运算符与二元运算符的组合
  • 三元运算符? :(内联条件),根据问号之前指定的条件返回冒号的左侧或右侧
  • 括号()用于更改运算符优先级

此外,C#还提供了一些内置方法(定义为一元运算符),如typeofsizeof,以及一组非常实用的类型运算符:

  • 标准类型转换运算符(),如C语言
  • 引用类型转换运算符as
  • 类型一致性检查器is
  • null合并运算符??
  • 继承运算符:

让我们来看看这些类型和运算符的实际应用。

using System;

class Program
{
    static void Main(string[] args)
    {
        //5 here is an integer literal
        int a = 5;

        //A double value is a floating point literal.
        double x = 2.5 + 3.7;
        //A single value is given by a floating point literal with the suffix f.
        float y = 3.1f;

        //Using quotes will automatically generated constant strings
        string someLiteral = "This is a string!";
        string input = Console.ReadLine();

        //int, string, double, float, ... are just keywords - in fact, they are represented
        //by Int32, String, Double, Single - which have static (and non-static) members.
        a = int.Parse(input);//Here we use the static method [int Parse(string)] of Int32.

        //Output of a mod operation - adding string and something (or something and string)
        //will always result in a string. Additionally we have the regular operator ordering
        //which performs a % 10 before the string and a get concatenated.
        Console.WriteLine("a % 10 = " + a % 10);
    }
}

我们将在本系列教程中看到更多运算符和基本类型。应该注意的是,所有运算符默认都返回一个新类型,而原始变量保持不变。另一个重要方面是存在固定的运算符优先级,这基本上与C语言的运算符优先级相同。我们不需要学习它,因为我们可以随时使用括号来改变默认的优先级。此外,运算符优先级非常自然,遵循诸如“点优先于破折号”之类的规则,即乘法和除法优先于加法和减法。

引用类型和值类型

理解C#的一个非常重要的概念是引用类型和值类型之间的区别。一旦我们使用class,我们就使用的是引用类型。另一方面,任何struct对象都将作为值类型处理。重要的区别在于引用类型是通过引用传递的,即我们只传递对象的某个位置的副本,而不是对象本身的副本。对对象的任何修改都将导致对原始对象的修改。

另一种情况是值类型。如果我们向方法传递一个struct参数(包括整数、浮点数、布尔值、字符等),我们将获得该对象的副本。这意味着对参数的修改永远不会导致对原始对象的修改。

整个概念与C语言中的指针概念非常相似。唯一的区别是我们实际上没有访问地址,并且不必取消引用变量来获取其背后的值。可以说,C#自动处理了一些我们在C/C++中需要手动编写的事情。

在C#中,我们可以访问两个关键字,它们可以与参数定义一起传递。一个重要的关键字是ref。这允许我们在传递结构体时访问原始值。对于类,结果是传递了实际的原始指针,允许我们更改其位置。这乍一听有点奇怪,但我们将看到在大多数情况下,对类实例使用显式ref与仅通过传递类来隐式传递类没有区别。唯一的区别是我们实际上可以重置指针,如下面的代码所示。

using System;

class Program
{
    static void Main()
    {
        //A string is a class, which can be instantiated like this
        string s = "Hi there";
        Console.WriteLine(s);//Hi there
        ChangeString(s);
        Console.WriteLine(s);//Hi there
        ChangeString(ref s);
        Console.WriteLine(s);//s is now a null reference
    }

    static void ChangeString(string str)
    {
        str = null;
    }

    static void ChangeString(ref string str)
    {
        str = null;
    }
}

另一个重要的关键字是out。基本上,out就像ref。有几个值得一提的区别:

  • out变量需要在拥有它们的函数中进行赋值。
  • out变量不需要在传递它们的函数中进行赋值。
  • out变量将变量标记为用于(额外的)传出信息。

out参数的主要用途顾名思义:我们现在可以选择区分“仅”引用和实际返回内容的参数。.NET框架在某些场景中使用out参数。其中一些场景是基本结构(如intdouble等)上的TryParse方法。在这里,返回一个bool,为我们提供一个指示给定的string是否可以转换的指标。如果转换成功,则以out参数形式给出的变量将被设置为相应的值。

如果我们不知道如何创建这些类型,那么关于引用(class)和值(struct)类型的谈论都是无用的。让我们看一个简单的例子。

using System;

class Program
{
    static void Main(string[] args)
    {
        //Usually C# forbids us to leave variables uninitialized
        SampleClass sampleClass;
        SampleStruct sampleStruct;

        //However, C# thinks that an out-Function will do the initialization
        HaveALook(out sampleClass);
        HaveALook(out sampleStruct);
    }

    static void HaveALook(out SampleClass c)
    {
        //Insert a breakpoint here to see the
        //value of c before the assignment:
        //It will be null...
        c = new SampleClass();
    }

    static void HaveALook(out SampleStruct s)
    {
        //Insert a breakpoint here to see the
        //value of s before the assignment:
        //It will NOT be null...
        s = new SampleStruct();
    }

    //In C# you can created nested classes
    class SampleClass
    {
    }

    //A structure always inherits ONLY from object,
    //we cannot specify other classes (more on that later)
    //However, an arbitrary number of interfaces can
    //be implemented (more on that later as well)
    struct SampleStruct
    {
    }
}

在上面的示例中,我们创建了两个名为SampleClassSampleStruct的类型。我们可以使用new关键字从这些类型定义中实例化新对象。对于来自Java或C++的程序员来说,这并不新鲜(这是个双关语),但对于来自C的程序员来说,这绝对是新事物。在C中,对于类,我们会使用malloc函数(给我们一个指针),而对于结构体,我们什么都不做(给我们一个值)。然而,使用new关键字有一个很大的优点:它不仅会进行正确的内存分配(引用类型在堆上,值类型在栈上),还会调用相应的构造函数。稍后我们将看到什么是构造函数,以及它给我们带来哪些好处。

回到我们的示例,我们看到在Main方法中我们没有使用new关键字实例化任何东西,但我们在名为HaveALook的方法中使用了它,这些方法通过它们期望的参数类型而有所不同。使用这些方法中的断点,我们可以看到类变量实际上并没有被设置(传递的变量值是null,这是未设置指针的常量),而结构体已经具有某个值。

控制流

现在我们已经介绍了C#背后的基本概念,以及基本数据类型和可用运算符,在我们能够实际编写C#程序之前,我们还需要一件事。我们需要知道如何控制程序流,即如何引入条件和循环。

这与C语言几乎相同,因为我们处理的是C风格的语法。也就是说,我们必须遵守这些规则:

  • 条件可以通过ifswitch引入。
  • 可以通过forwhiledo-while来设置循环。
  • 使用break关键字可以停止循环(将停止最内层的循环)。
  • 使用continue关键字可以跳过其余部分并返回到条件。
  • C#还有一个称为foreach的迭代器循环。
  • 另一种可能性是臭名昭著的goto语句——我们不会讨论它。
  • 还有其他控制程序流的方法,但我们将在稍后介绍。

下一个程序代码将介绍上述的一些可能性。

using System;

class Program
{
    static void Main(string[] args)
    {
        //We get some user input with the ReadLine() method
        string input = Console.ReadLine();

        //Let's see if this is empty or not
        if (input == "")
        {
            Console.WriteLine("The input is empty!");
        }
        else
        {
            //A string is a char array and has a Length property
            //It also can be accessed like a char array, giving
            //us single chars.
            for (int i = 0; i < input.Length; i++)
            {
                switch (input[i])
                {
                    case 'a':
                        Console.Write("An a - and not ... ");
                        //This is always required, we cannot just fall through.
                        goto case 'z';
                    case 'z':
                        Console.WriteLine("A z!");
                        break;
                    default:
                        Console.WriteLine("Whatever ...");
                        //This is required even in the last block
                        break;
                }
            }
        }
    }
}

foreach迭代器循环在C语言中不可用。对于定义了某种迭代器的任何类型,都可以使用foreach。我们稍后将看到这意味着什么。现在,我们只需要知道每个数组都定义了一个这样的迭代器。以下代码片段将使用foreach循环输出数组的每个元素。

//Creating an array is possible by just appending [] to any data type
int[] myints = new int[4];
//This is now a fixed array with 4 elements. Arrays in C# are 0-based
//hence the first element has index 0.
myints[0] = 2;
myints[1] = 3;
myints[2] = 17;
myints[3] = 24;

//This foreach construct is new in C# (compared to C++):
foreach(int myint in myints)
{
    //Write will not start a new line after the string.
    Console.Write("The element is given by ");
    //WriteLine will do that.
    Console.WriteLine(myint);
}

for相比,foreach有一些限制。首先,它不如for循环高效,开始循环需要额外的一个操作,并且在每次迭代结束时始终调用迭代器的Next方法。其次,我们无法更改当前元素。这是因为foreach操作的是迭代器,迭代器通常是不可变的,即单个元素不能被更改。这也是为了保持迭代的一致性。

面向对象编程

面向对象编程是一种将焦点放在对象而不是函数上的方法。因此,类型声明是面向对象编程的关键方面。一切都必须是某个类型的一部分,即使它只是静态的,没有任何实例依赖。

这种模式当然也有缺点。我们不能写sin()cos()sign()等,而是必须写Math.Sin()Math.Cos()Math.Sign(),因为(非常有用的)数学函数也需要放在一个类型中(在这种情况下是Math类)。

那么,面向对象编程的关键方面是什么?

  • 数据封装
  • 继承
  • 类型之间的关系
  • 声明依赖关系
  • 可维护性
  • 可读性

通过创建类来承载大型、可重用的数据包,我们实现了封装。继承过程有助于我们标记类型之间的强关系并重用相同的基本结构。将函数封装在类型中将把属于一起的东西分组,并通过省略必需的参数来减少代码。此外,默认情况下会防止误用。总而言之,主要目标是通过提高可读性和增强编译器在错误检测方面的能力来减少维护工作。

OOP的主要概念是类型焦点。核心类型无疑是类。结构也很重要,但只会在特殊情况下使用。如果我们只有少量数据,或者想创建相当基础的小型类型,其不可变性比类更强,那么结构就很有意义。

让我们再次看看我们是如何创建类(类型)以及如何创建类对象(实例)。

//Create a class definition
class MyClass
{
    public void Write(string name)
    {
        Console.WriteLine("Hi there... {0}!", name);
    }
}

//Create a class instance (code to be placed in a method)
MyClass instance = new MyClass();

当我们想要重用一组方法,并且除了方法的一些参数外,还需要一组固定变量时,创建一个类就很有意义。当我们想要使用一组已有的变量和/或方法时,类也非常有用。如果我们只想收集一组与任何固定(称为实例依赖)变量集无关的函数,那么我们创建static类,我们可以在其中插入static方法和变量。这类static类的良好示例是ConsoleMath类。它们无法被实例化(static类的实例,即不包含实例依赖代码的类,没有任何意义),并且只提供带有一组参数的函数。

继承和多态

现在我们来讨论继承问题。为了简化,我们可以将继承视为编译器的一种递归复制粘贴过程。父类(base)的所有成员都将被复制。

class MySubClass : MyClass
{
    public void WriteMore()
    {
        Console.WriteLine("Hi again!");
    }
}

如前所述,继承运算符是:。在此示例中,我们创建了一个名为MySubClass的新类型,它继承自MyClassMyClass已在上一节中定义,并且没有定义显式继承。因此,MyClass继承自ObjectObject本身只定义了四个方法,它们是:

  • ToString,这是定义类型实例如何表示为字符串的非常方便的方式
  • Equals,这是比较两个任意对象是否相等的通用方法
  • GetHashCode,它获取一个数字指示符,用于判断两个对象是否可能相等
  • GetType,它获取当前实例特定类型的元信息

这四个方法在MyClassMySubClass实例中可用(复制粘贴!)。此外,MyClass定义了一个名为Write的方法,该方法也可用于所有MySubClass实例。最后,MySubClass定义了一个名为WriteMore的方法,该方法仅可用于MySubClass实例。

目前,继承概念已经有点用处,但它不是很强大。多态的概念将使我们能够通过继承来特化对象。首先,我们将介绍virtual关键字。这个关键字允许我们指定一个(标记为virtual的)方法可以被更专门的(或派生的)类重新实现。

class MyClass
{
    //This method is now marked as re-implementable
    public virtual void Write(string name)
    {
        //Using a placeholder in a string.Format()-able method
        Console.WriteLine("Hi {0} from MyClass!", name);
    }
}

如果我们现在想在MySubClass类中重新实现Write方法,那么我们必须通过将重新实现标记为override来显式执行。让我们看一下:

class MySubClass : MyClass
{
    //This method is now marked as re-implemented
    public override void Write(string name)
    {
        Console.WriteLine("Hi {0} from MySubClass!", name);
    }
}

这有什么好处呢?让我们看一些示例代码片段。

//We create two variables of type MyClass
MyClass a = new MyClass();
MyClass b = new MySubClass();

//Now we call the Write() method on each of them, the only
//difference being that in fact b is a more specialized type
a.Write("Flo"); //Outputs ... from MyClass!
b.Write("Flo"); //Outputs ... from MySubClass!

所以诀窍在于,即使不知道它背后的更专业的实例,我们也能访问mySubClass中可用的专业化实现。这称为多态,基本上意味着类可以重新实现某些方法,然后可以再次使用这些方法,而无需知道其专业化或重新实现。

在这里,我们已经可以从多态中受益,因为我们可以overrideObject提供的四个方法。让我们考虑以下示例:

using System;

class Program
{
    static void Main(string[] args)
    {
        MyClassOne one = new MyClassOne();
        MyClassTwo two = new MyClassTwo();

        Console.WriteLine(one);//Displays a strange string that is basically the type's name
        Console.WriteLine(two);//Displays "This is my own class output"
    }
}

class MyClassOne
{
    /* Here we do not override anything */
}

class MyClassTwo
{
    public override string ToString()
    {
        return "This is my own class output";
    }
}

在这里,WriteLine方法通过使用ObjectToString方法解决了将任何输入显示为字符序列的问题。这使得WriteLine能够输出任何对象,即使是未知的对象。WriteLine所关心的只是给定参数实际上是Object的一个实例(这适用于C#中的所有对象),这意味着该参数有一个ToString方法。最后,调用参数的特定ToString方法。

访问修饰符

访问修饰符在强制程序员遵守给定的面向对象设计方面起着重要作用。它们隐藏成员以防止未定义的访问,定义哪些成员参与继承过程以及哪些对象在库外部可见。

在此,我们必须注意到,修饰符施加的所有限制都仅仅是人为的。编译器是这些规则的唯一保护者。这意味着这些规则不会阻止在运行时未经授权访问例如变量。因此,设置访问修饰符来制造某种安全系统绝对是一个非常糟糕的主意。这些修饰符背后的主要思想与面向对象编程相同:创建封装数据并强制其他程序员遵循特定访问模式的类。这样,查找使用特定对象的正确方法应该更简单、更直接。

C#知道很多这样的修饰符关键字。让我们用简短的描述来看一下:

  • private,声明成员既不从对象外部可见,也不参与继承过程
  • protected,声明成员不从对象外部可见,但该成员参与继承过程
  • internal,声明成员或类型在对象外部可见,但在当前库外部不可见
  • internal protected,具有internalprotected的含义
  • public,声明成员或类型在任何地方都可见

大多数时候,我们可以指定修饰符(此规则有一些例外,我们稍后会看到),但我们也可以随时省略它。对于直接放置在命名空间中的类型,默认修饰符是internal。这很有意义。对于放置在类型(如类或结构)中的类型和成员,默认修饰符是private

这很有意义,因为这与C++类中的选择相同:但是,对于C++中的struct,我们总是应该从private声明开始,否则所有成员都将是public。这是为了确保与C兼容的逻辑选择。在这方面,C#更加一致(和可预测),始终使用最强的访问修饰符,而与structclass选择无关(即,在C#中,除非明确说明,否则任何东西都不是public)。

using System;

//No modifier, i.e., the class Program is internal
class Program
{
    //No modifier, i.e., the method Main() is private
    static void Main()
    {
        MyClass c = new MyClass();
        //Works
        int num = c.WhatNumber();
        //Does not work
        //int num = c.RightNumber();
    }
}

//MyClass is visible from this library and other libraries
public class MyClass
{
    //This one can only be accessed from MyClass
    private int a;

    //Classes inheriting from MyClass can access b like MyClass can
    protected int b;

    //No modifier, i.e., the method RightNumber() is private
    int RightNumber()
    {
        return a;
    }

    //This will be seen from the outside
    public int WhatNumber()
    {
        //Access inside the class is possible
        return RightNumber();
    }
}

//MySubClass is only visible from this library
internal class MySubClass : MyClass
{
    int AnotherRightNumber()
    {
        //Works
        b = 8;
        //Does not work - a cannot be accessed since it is private
        return a;
    }
}

编译器会强制执行一些限制。上面的示例的逆向情况,即我们将MyClass设置为internal而将MySubClass设置为public是不可能的。编译器会检测到,MySubClass对外部可见必须要求MyClass也对外部可见。否则,我们将拥有一个基础类型未知的类型的专门化。

通常也是如此,例如当我们返回一个internal类型的实例到一个对外部可见的方法(public,且类型为public)时。在这种情况下,编译器也会告诉我们返回的类型的访问修饰符更强。

在C#中,每个非静态方法都可以访问类实例指针变量this。该变量被视为关键字,并指向当前类实例。通常,在调用类实例的方法之前可以省略该关键字,但有多种情况this非常有用。

其中一种情况是区分局部变量和全局变量。考虑以下示例:

class MyClass
{
    string str;

    public void Change(string str)
    {
        //Here this.str is the global variable str and
        //str is the local (passed as parameter) variable
        this.str = str;
    }
}

由于标记为static的方法独立于实例,因此我们无法使用this关键字。除了this指针之外,还有一个base指针,它允许我们访问基类实例的所有(对派生类可访问的)成员。这样,就可以调用已重新实现或隐藏的方法。

class MySubClass : MyClass
{
    public override void Write(string name)
    {
        //First we want to use the original implementation
        base.Write(name);
        //Then our own
        Console.WriteLine("Hi {0} from MySubClass!", name);
    }
}

在示例中,我们从重新实现中访问Write方法的原始实现。

属性

来自C++的程序员会知道限制类变量访问的问题。一般来说,不应该暴露类的变量,以免其他类在没有通知类的情况下对其进行更改。因此,下面的代码在C++中经常被编写(代码用C#给出):

private int myVariable;

public int GetMyVariable()
{
    return myVariable;
}

public void SetMyVariable(int value)
{
    myVariable = value;
}

这是一段干净的代码,我们(作为开发人员)现在有机会通过在myVariable = value之前插入几行代码来响应变量的外部更改。这段代码的问题是:

  • 我们实际上只想表明这只是myVariable的包装器,并且
  • 我们需要为这个简单的模式编写过多的代码。

因此,C#团队引入了一个名为属性的新语言特性。使用属性,上面的代码可以简化为:

private int myVariable;

public int MyVariable
{
    get { return myVariable; }
    set { myVariable = value; }
}

这看起来要干净得多。此外,访问也发生了变化。以前我们像调用方法一样访问myVariable(使用a = GetMyVariable()SetMyVariable(b)),而现在我们像访问变量一样访问myVariable(使用a = MyVariableMyVariable = b)。这更符合程序员的初衷,并为我们节省了几行代码。

在内部,编译器仍然会创建那些(get/set)方法,但我们不在乎。我们将只使用属性,其中包含get块、set块,或者两者都包含,并且一切都会正常工作。

请注意,Microsoft Visual C++还有一个__declspec关键字的扩展,使我们能够在C++中编写类似于C#属性的属性。

构造函数

构造函数是一种特殊类型的方法,只能隐式调用,绝不能显式调用。当我们使用new关键字分配内存时,会自动调用构造函数。与标准方法完全一致,我们可以通过具有不同参数的多个定义来重载构造函数。

每个类(和结构)至少有一个构造函数。如果我们没有编写构造函数(到目前为止我们没有),那么编译器会插入一个标准的(无参数,空体)构造函数。一旦我们定义了一个构造函数,编译器就不会插入默认构造函数。

构造函数的签名很特别。它没有返回类型,因为它隐式返回新实例,即类的实例。构造函数也由其名称定义,该名称与类名相同。让我们看一些构造函数:

class MyClass
{
    public MyClass()
    {
        //Empty default constructor
    }

    public MyClass(int a)
    {
        //Constructor with one argument
    }

    public MyClass(int a, string b)
    {
        //Constructor with two arguments
    }

    public MyClass(int a, int b)
    {
        //Another constructor with two arguments
    }
}

这看起来很直接。简而言之,构造函数是一个与类同名但未指定返回类型的方法。通过实例化类对象可以使用各种构造函数。

MyClass a = new MyClass();//Uses the default constructor
MyClass b = new MyClass(2);//Uses the constructor with 1 argument
MyClass c = new MyClass(2, "a");//Uses the constructor with 2 arguments
MyClass d = new MyClass(2, 3);//Uses the other constructor with 2 arguments

当然,一个构造函数可能需要做与另一个构造函数相同的工作。在这种情况下,我们似乎只有两个选择:

  1. 复制和粘贴内容。
  2. 将内容提取到一个方法中,然后由两个构造函数调用。

第一个根据DRY(Don't Repeat Yourself)原则是绝对不行。第二个可能也不好,因为这可能导致该方法在其他地方被滥用。因此,C#引入了构造函数链的概念。在我们实际执行一个构造函数的指令之前,我们调用另一个构造函数。语法依赖于冒号:和当前类实例指针this

class MyClass
{
    public MyClass()
        : this(1, 1) //This calls the constructor with 2 arguments
    {
    }

    public MyClass(int a, int b)
    {
        //Do something with a and b
    }
}

在这里,默认构造函数使用带有两个参数的构造函数来执行一些初始化工作。初始化工作是构造函数最常见的用例。构造函数应该是一个轻量级的方法,用于进行一些预处理/设置/变量初始化。

构造函数链的冒号运算符是出于原因的。与继承一样,每个构造函数都必须调用另一个构造函数。如果没有指定调用(即没有之前的构造函数),那么调用的构造函数就是基类的默认构造函数。因此,上一个示例中的第二个构造函数实际上看起来像这样:

public MyClass(int a, int b)
    : base()
{
    //Do something with a and b
}

但是,附加行是多余的,因为编译器会自动插入它。只有两种情况需要我们为构造函数链指定基类构造函数:

  1. 当我们实际上想调用一个不是基类默认构造函数的基类构造函数时,以及
  2. 当基类没有默认构造函数时。

构造函数链与基类构造函数的原因在下图所示:

The principle of chaining constructors

我们看到在这个类层次结构中,为了创建Porsche的实例,必须创建一个Car的实例。然而,这个创建需要创建一个Vehicle的实例,而这又需要实例化Object。每次实例化都与调用构造函数相关联,该构造函数必须被指定。C#编译器会自动调用空构造函数,但这只有在存在此类构造函数的情况下才可能。否则,我们必须明确告诉编译器调用哪个。

还有一些情况,构造函数的其他访问修饰符可能是有意义的。如果我们想阻止实例化某个类型(如abstract),我们可以创建一个默认构造函数并将其设为protected。另一方面,以下是一个简单的所谓“单例”模式:

class MyClass
{
    private static MyClass instance;

    private MyClass() { }

    public static MyClass Instance
    {
        get 
        {
            if (instance == null)
                instance = new MyClass();

            return instance;
        }
    }
}

现在我们不能创建类的实例,但我们可以通过MyClass.Instance访问static属性Instance。这个属性不仅可以访问static变量instance,还可以访问所有private成员,如private构造函数。因此,它可以创建一个实例并返回创建的实例。

这种实现有两个主要优点:

  • 由于实例是在Instance属性方法内部创建的,因此该类可以执行其他功能(例如,实例化子类),即使这可能会引入不受欢迎的依赖关系。
  • 实例化直到对象请求实例时才执行。这种方法称为“惰性实例化”。这避免了在应用程序启动时实例化不必要的单例。

在本系列教程中,我们不会讨论其他设计模式。

抽象类和接口

在本教程中,我们还需要讨论一件事。有时,我们想创建仅仅是为了更专门的实现而准备的草图的类。这就像为类创建模板。我们不想直接使用模板(实例化它),而是想从类派生(使用模板),这应该能为我们节省时间。用于将类标记为模板的关键字是abstract。抽象类不能被实例化,但当然可以作为类型使用。这样的类也可以将成员标记为abstract。这将要求派生类提供实现。

abstract class MyClass
{
    public abstract void Write(string name);
}

class MySubClass : MyClass
{
    public override void Write(string name)
    {
        Console.WriteLine("Hi {0} from an implementation of Write!", name);
    }
}

在这里,我们将Write方法标记为抽象,这有两个后果:

  1. 在第一个方法定义中没有方法体(缺少花括号)。
  2. MySubClass被要求overrideWrite方法(或一般情况下:所有标记为abstract且尚未实现的 #-}方法)。

下面的代码也会失败,因为我们创建了一个MyClass的实例,而该实例现在被标记为abstract

//Ouch, MyClass is abstract!
MyClass a = new MyClass();
//This works fine, MyClass can still be used as a type
MyClass b = new MySubClass();

在C#中使用OOP的一个重要限制是只能继承一个类。如果我们不指定基类,则隐式使用Object,否则使用显式指定的类。继承过程中的单类限制是有意义的,因为它保持了所有内容的明确定义并防止了奇怪的极端情况。有一个优雅的解决方法,它建立在使用所谓的interface类型。

interface就像一份代码合同。接口定义了实现它们的类或结构应该提供哪些功能,但它们没有说明确切的功能是什么样的。也就是说,我们可以将这些接口视为没有变量且只有abstract成员(方法、属性等)的抽象类。

让我们定义一个非常简单的接口:

interface MyInterface
{
    void DoSomething();

    string GetSomething(int number);
}

定义的接口包含两个名为DoSomethingGetSomething的方法。这些方法的定义与抽象方法的定义非常相似,只是我们缺少publicabstract关键字。这是故意的。想法是,由于接口的每个成员都是abstract(或者更准确地说:缺少实现),所以关键字是多余的。另一个特性是每个方法都自动被视为public

通过使用与类相同的语法可以实现接口。让我们考虑两个例子:

class MyOtherClass : MyInterface
{
    public void DoSomething()
    { }

    public string GetSomething(int number)
    {
        return number.ToString();
    }
}

class MySubSubClass : MySubClass, MyInterface
{
    public void DoSomething()
    { }

    public string GetSomething(int number)
    {
        return number.ToString();
    }       
}

这个片段应该演示一些事情:

  • 有可能只实现一个接口而不实现任何类(这将导致直接继承自Object)。
  • 我们还可以实现一个或多个接口,并且另外实现一个类(显式继承)。
  • 我们总是必须实现“继承”接口的所有方法。
  • 另外,我们不需要在MySubSubClass中重新实现Write方法,因为MySubClass已经实现了它。

应该清楚的是,我们不能实例化接口(它们就像抽象类),但我们可以将它们用作类型。因此,可以这样做:

MyInterface myif = new MySubSubClass();

.NET框架中的接口类型通常以大写字母I开头。这是一个识别接口的有用约定。在我们接下来的旅程中,我们将发现一些对.NET框架非常重要的有用接口。其中一些接口被C#隐式使用。

接口还为我们提供了另一种实现方法的方式。由于我们可以实现多个接口,因此可能包含具有相同名称和签名的两个方法。在这种情况下,必须有一种方法来区分不同的实现。这可以通过所谓的显式实现来实现。显式实现的接口不会直接贡献给类。相反,必须将类转换为特定的接口类型才能访问接口的成员。

这是一个显式实现:

class MySubSubClass : MySubClass, MyInterface
{
    //Explicit (no public and MyInterface in front)
    void MyInterface.DoSomething()
    { }

    //Explicit (no public and MyInterface in front)
    string MyInterface.GetSomething(int number)
    {
        return number.ToString();
    }       
}

接口定义的显式和隐式实现可以混合。因此,如果我们必须将实例转换为该接口,我们才能确保能够访问接口定义的所有成员。

异常处理

C#中有许多设计时就考虑了OOP的特性。其中之一就是异常处理。每个异常都必须派生自Exception类,该类位于System命名空间中。总的来说,我们应该始终尽量避免异常,但有些情况可能很容易发生异常。一个例子是在与文件系统通信时。在这里,我们正在与操作系统通信,而操作系统有时别无选择,只能抛出异常。可能的原因有很多,例如:

  • 给定的路径无效。
  • 找不到文件。
  • 我们没有足够的权限访问文件。
  • 文件已损坏,无法读取。

当然,操作系统API可以返回一个伪文件或伪内容,一切都会正常工作。这种处理的问题在于它不能反映现实,并且我们无法检测到显然有问题。另一个选择是返回一个错误代码,但这会导致C语言风格的API,并将处理留给程序员。如果程序员现在做得不好(例如忽略返回的错误代码),用户将永远看不到有问题。

这就是异常发挥作用的地方。异常的重要之处在于,一旦可能发生异常,我们就应该考虑处理它。为了处理这种异常,我们需要一种对其做出反应的方式。结构与C++或Java相同:可以捕获抛出的异常。

try
{
    FunctionWhichMightThrowException();
}
catch
{
    //React to it
}

在示例中,我们调用了一个名为FunctionWhichMightThrowException的方法。调用此方法可能会导致异常,因此我们将其放在try块中。只有在抛出异常时才会进入catch块,否则将被忽略。然而,这个示例无法做到的是对特定异常做出反应。目前,我们只是对任何异常做出反应,而没有触及抛出的异常。然而,这一点非常重要,因此应该做到:

try
{
    FunctionWhichMightThrowException();
}
catch (Exception ex)
{
    //React to it e.g.
    Console.WriteLine(ex.Message);
}

由于每个异常都必须派生自Exception,因此这总是会奏效,我们总是能够访问Message属性。这是一个所谓的“捕获一切”块。然而,有时我们想区分各种异常。回到上面关于文件系统的示例,我们可以预期每个独特的情况(例如,路径无效、文件未找到、权限不足等)都会抛出不同的异常。我们可以通过定义更多catch块来区分这些异常:

byte[] content = null;

try
{
    content = File.ReadAllBytes(/* ... */);
}
catch (PathTooLongException)
{
    //React if the path is too long
}
catch (FileNotFoundException)
{
    //React if the file has not been found
}
catch (UnauthorizedAccessException)
{
    //React if we have insufficient rights
}
catch (IOException)
{
    //React to a general IO exception
}
catch (Exception)
{
    //React to any exception that is not yet handled
}

这个例子应该有两个教训:

  1. 我们可以指定多个catch块,每个块都有自己的处理程序。唯一的限制是,我们应该按这样的顺序指定,即最通用的异常最后调用,而最具体的异常首先调用。
  2. 我们不需要为异常指定变量名。如果我们命名它,我们将能够访问Exception对象,但有时我们不关心特定对象。相反,我们只想区分各种异常。

现在我们已经能够捕获那些讨厌的异常了,我们可能想自己抛出异常。这是通过使用throw关键字完成的。让我们看一些示例代码:

void MyBuggyMethod()
{
    Console.WriteLine("Entering my method");
    throw new Exception("This is my exception");
    Console.WriteLine("Leaving my method");
}

如果我们调用此方法,我们将看到第二个WriteLine方法不会被调用。一旦抛出异常,方法将立即退出。这会一直持续到有合适的try-catch块包装方法调用为止。如果没有找到这样的块,应用程序就会崩溃。这种行为称为冒泡。另外,我们也可以编写自己的派生自Exception的类:

class MyException : Exception
{
    public MyException()
        : base("This is my exception")
    {
    }
}

现在,我们上面的代码可以修改为如下:

void MyBuggyMethod()
{
    Console.WriteLine("Entering my method");
    throw new MyException();
    Console.WriteLine("Leaving my method");
}

再次回到我们玩文件系统的示例。在这种情况下,我们可能会最终得到一些打开的文件句柄。因此,无论我们是否遇到异常,我们都希望关闭该句柄以清理打开的资源。在这种情况下,另一个块将非常有帮助。一个执行最终操作的块,该操作不依赖于try或任何catch块中的操作。当然,这样的块存在,并且被称为finally块。

FileStream fs = null;

try
{
    fs = new FileStream("Path to the file", FileMode.Open);
    /* ... */
}
catch (Exception)
{
    Console.WriteLine("An exception occurred.");
    return;
}
finally
{
    if (fs != null)
        fs.Close();
}

在这里,我们应该注意到return在某个块中仍然会调用finally块中的代码。所以总的来说,我们有使用try-catchtry-catch-finallytry-finally块的选项。最后一个不会捕获异常(即异常会冒泡),但仍然会调用finally块中的代码(无论try块中发生什么)。

Outlook(展望)

在下一个教程中,我们将学习C#中更高级的特性,并扩展我们在面向对象编程方面的知识。随着我们在C#方面的知识不断提高,我们将准备更深入地研究.NET框架。

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. Lecture Notes Part 3 of 4 - Advanced Programming with C#(讲义 第三部分 - C# 高级编程)
  4. Lecture Notes Part 4 of 4 - Professional Techniques for C#(讲义 第四部分 - C# 专业技术)

参考文献

历史

  • v1.0.0 | 初始发布 | 2016年4月18日
  • v1.0.1 | 添加了文章列表 | 2016年4月19日
  • v1.0.2 | 刷新了文章列表 | 2016年4月20日
  • v1.0.3 | 刷新了文章列表 | 2016年4月21日
  • v1.1.0 | 感谢Steve指出了VS社区版 | 2016年4月22日
  • v1.1.1 | 更新了一些拼写错误 | 2016年4月23日
  • v1.2.0 | 更新了带有锚点的结构 | 2016年4月25日
  • v1.2.1 | 添加了目录 | 2016年4月29日
  • v1.2.2 | 添加了Kenneth Haugland和Prateek Dalbehera的评论 | 2016年5月3日
  • v1.2.3 | 根据奥地利方面的更正进行了更新 | 2016年5月5日
  • v1.2.4 | 澄清了考虑的重载签名 | 2016年10月2日
  • v1.2.5 | 提到了VS CE、Code、ST、Atom、OmniSharp | 2016年10月4日
C#高级入门 - 讲义 - 第1部分共4部分 - CodeProject - 代码之家
© . All rights reserved.