1.3 类和对象

面向对象编程中,最基本的概念就是“类”和“对象”,深刻把握这两个概念,在编程中时刻具备“面向对象”的意识,对于一个.NET工程师而言非常重要。

1.3.1 类、类的实例和对象

请看示例项目UseForm(见图1-12),当它运行时,每点击一次主窗体上的按钮,就会在屏幕上出现一个从窗体,尽管这些从窗体外观一样,但彼此之间是完全独立的,比如每个从窗体都可以在屏幕上自由移动和修改大小,不会影响到其他的窗体。

这个简单的Windows Forms示例程序的背后,隐藏着.NET面向对象编程的最基本特性。

打开UseForm项目的源码,可以看到其中定义了两个窗体类:frmMain(代表主窗体)和frmOther(代表从窗体)。主窗体上按钮单击事件的响应代码如下:

图1-12 示例程序UseForm

private void btnShowOtherForm_Click(object sender,EventArgs e)
{
   frmOther frm=new frmOther();//创建从窗体对象
   frm.Show();                      //显示从窗体
}

上述代码包容了整个示例程序的“核心机密”。

程序运行时我们看到的从窗体,其实都是frmOther类的“对象(Object)”。在屏幕上看到了多少个从窗体,实际中就有多少个frmOther对象存在。

程序员所编写的代码放在frmMain或frmOther类中,但在程序运行时,“类”根本就不存在,存在的仅仅是“对象”。

为什么所看到的“从窗体”都一模一样?

因为它们都是以frmOther类作为“模板”创建出来的。

所以,对象是以类为模板而创建出来的实例

在面向对象领域,“对象”这个概念与“类的实例”是等同的,它们指代同一事物。我们有时会将“创建对象”称为“类的实例化”。

在示例程序中,我们看到有一个主窗体(frmMain)对象和多个从窗体(frmOther)对象,而它们之间的“地位”是不同的,主窗体对象负责创建从窗体对象,关闭从窗体对象对其他窗体对象没有影响,而关闭主窗体对象,将导致屏幕上现有的所有窗体全部“消失”,程序运行结束。由此可知,对象在程序中可以拥有不同的地位、作用和角色。

从中我们可以得出另外一个结论:

面向对象的程序在运行时,会创建多个对象,这些对象之间可能有着复杂的关联,它们相互协作,共同完成应用程序所提供的各项功能。

可以将上述结论以一个简单的公式来表达:

正在运行的面向对象的程序=对象+对象之间的相互协作关系

在面向对象程序的开发阶段,“类”是核心,而在面向对象程序运行之后,“对象”是核心。

举个例子,在使用ASP.NET开发的Web应用程序中,每个.aspx网页其实就是一个类。当用户使用浏览器向Web服务器发出一个访问特定.aspx网页的HTTP请求时,“ASP.NET运行时(ASP.NET runtime)“ASP.NET runtime”是一个统称,它包容一组相互合作的ASP.NET系统核心对象,这些对象负责完成响应用户HTTP请求、管理ASP.NET应用程序等工作。”会依据请求的URL找到相应的.aspx网页,“装配”出一个完整的页面类(派生自基类Page,故称为“页面类”),然后以此页面类为模板创建一个页面对象,调用此页面对象的ProcessRequest方法生成HTML代码,然后发回给客户端浏览器。

所以,ASP.NET响应并处理HTTP请求的过程就是一个以页面对象的创建为核心的过程。

再举一个例子,在使用WCF开发的分布式系统中,客户端可以在本地创建一个“代理对象(proxy)”,此代理对象其实对应着远程服务器上的一个“WCF服务对象”,两者拥有一致的访问接口,客户端对此本地“代理对象”的访问请求,将会被转发到远程的服务器上,由相应的“WCF服务对象”负责响应。

在上面举的两个例子中,涉及了.NET两个重要的技术领域:ASP.NET和WCF,可以看到其中涉及多个对象间的合作问题,很明显如果不深刻地理解“类”和“对象”这两个基本概念,诸如ASP.NET和WCF这类复杂的技术,是无法掌握的。

在.NET世界里,在一个运行的.NET应用程序中,“对象”无处不在。

1.3.2 温故知新——面向对象编程的基本规则

使用C#编写一个类很容易,例如以下代码定义了一个MathOpt类,它完成两数相加的功能(参见示例代码UseMathOpt)。

class MathOpt
{
    public int Add(int x,int y)
    {
        return x+y;
    }
}

以下代码创建了一个MathOpt对象,并且使用它来计算两整数之和:

01 class Program
02 {
03    static void Main(string[] args)
04    {
05       MathOpt mathobj ;//定义MathOpt对象变量
06       mathobj=new MathOpt();//创建对象
07       int IResult=mathobj.Add(100,200);//调用类的整数相加方法
08       double FResult=mathobj.Add(5.5,9.2);
09       Console.WriteLine("100+200="+IResult);//输出结果
10       Console.WriteLine("5.5+9.2="+FResult);//输出结果
11    }
12 }

上述的示例非常简单,然而“麻雀虽小,五脏俱全”,这个看似简单的例子中却蕴含着面向对象编程的基本原理。

首先,可以看到所有功能代码都放在Main和Add两个方法放在类中的函数改称为“方法(method)”。“函数”是结构化编程中的术语,“方法”是面向对象编程中的术语,两者严格地说是有区别的,比如在C#中我们可以为方法定义public/private/protected等访问权限,而C语言中的函数就不具备划分这么细的访问权限特性。但实际上经常看到人们在面向对象开发中“混用”这两个术语指代同一事物,用多了也就“约定俗成”了。中,而这两个方法又分别放在类Program和MathOpt中,可由此总结出一个要点:

1.类是面向对象程序中最基本的可复用软件单元,类中可包含多个方法。

提示

这里有一个需要强调的地方:

在面向对象程序的源码中,不存在独立于类之外的方法。

但这只是C#编程语言的限制,并非CLR的限制,如果使用IL编程,完全可以定义一个“全局”的函数,而此函数并不归属于某个类型。

那么,编好了一个类,是否就可以直接使用呢?

仔细看看Main方法中的代码,第5句定义了一个MathOpt类型的变量mathobj,第6句使用new关键字创建了一个MathOpt对象,并用mathobj变量来引用这一对象。紧接着第7句调用MathOpt类的Add方法完成两数相加的功能。由此,总结出另外一个要点:

2.外界通过对象变量来调用类中的实例方法类中的方法有两种类型:实例方法和静态方法。本章中的方法都是实例方法。

不允许直接调用已归属于某个类的实例方法,是面向对象程序不同于结构化程序的特征之一。

现在修改Main方法中的代码,注释掉第6句创建对象的代码,再次编译程序,Visual Studio会报告:

使用了未赋值的局部变量“mathobj”

这说明C#编译器要求变量必须“显式”初始化后才能使用。

修改第5句代码,初始化mathobj变量为null值,再次编译可以成功。

MathOpt mathobj=null;//定义并初始化MathOpt对象变量

运行修改后的程序,Visual Studio这次报告出现了一个“未处理NullReferenceException”的错误。

当某个.NET程序在运行过程中引发了一个错误时,CLR会创建一个异常对象,将程序出错的信息封装到此对象中,NullReferenceException就是这样的一个异常对象,它包含的基本信息就是:“未将对象引用设置到对象的实例”。

交叉链接

CLR拥有一个异常处理子系统,向所有.NET应用程序(不论其编程语言如何)“一视同仁”地提供异常处理功能。有关这方面的内容,请看第6章《异常捕获与处理》。

现在分析一下:

为何示例程序的运行会引发NullReferenceException异常?

关键是因为我们没有使用new关键字创建MathOpt对象就直接使用了变量mathobj。不创建类的对象就直接使用是编程中的一种常见错误。程序改起来很简单,恢复被注释掉的第6句代码即可。

由此得到面向对象编程的第3个要点:

3.创建完类的对象并赋值给相同类型(或相兼容类型指对象赋值给基类型或它所实现的接口类型的变量。)的变量之后,可以通过此变量调用对象的实例方法,存取对象的字段(或属性)。

示例代码中mathobj这个变量用于引用一个真实的MathOpt对象,是一种“引用类型变量”,由于它引用一个对象,所以人们通常又将其称为“对象变量”。

对象变量拥有一个数据类型(即类),可以引用此类创建出的任何一个对象,对象变量与对象之间的引用关系是通过赋值语句确定的。

现在回到本节示例,在MathOpt.cs文件中再次修改代码,将Add方法前的public关键字删除后再编译程序,此时Visual Studio报告:

“UseMathOpt.MathOpt.Add(int,int)”不可访问,因为它受保护级别限制

由此得到了面向对象编程的第4个要点:

4.声明为public的方法可以被外界这里所说的“外界”,指的是需要调用此方法的、不归属于本类(或其子类)成员的代码。调用。

要点4说明,虽然一个类中可以有多个方法,但不是所有的方法都可以被外界调用的,只有声明为公有(public)的方法才行,对外界而言,类中的非公有方法等于不存在一样。这就体现出一个重要的面向对象基本特性——封装。

只要保持类中声明为public的成员定义不变,程序员可以在类的内部添加新的私有成员,这种改变不会影响到外界调用代码的运行。这种“封装”特性使得在开发大规模的系统时多个软件工程师可以相互独立地工作,只需相互协商好类的对外接口(指类中声明为public的成员),就不必担心他们的工作成果无法协同工作。

1.3.3 “匿名”的对象类型

在C# 3.0以上版本中,我们可以使用var关键字定义一种奇怪的“没有类型”的变量(称为“隐式类型的局部变量”):

var Sum=100;
Console.WriteLine(Sum * 2);//输出:200

貌似CLR很聪明地可以动态推断出Sum是一个int类型的变量。

事实上,是C#编译器而不是CLR完成了类型推断的工作,C#编译器根据赋的值“100”推断Sum是一个int类型的变量,就直接生成了将常量“100”赋值给它的IL代码,CLR仅仅只是机械地执行罢了。

当使用var定义隐式类型的局部变量时,必须保证编译器能推断得出变量类型,否则,不能通过编译。

var只能用于方法内部定义局部变量,不能定义为类的字段。例如,以下代码将无法通过编译:

class A
{
    var Value=100;
}

之所以在这里介绍var关键字,是因为我们可以利用它实现“不定义类而直接创建一个对象”的目的。

请看以下代码(示例程序UseAnonymousType):

var v=new { Amount=108,Message="Hello"};

上述代码创建了一个匿名类型对象v,它拥有两个字段:Amount为int型,而Message为string型。

匿名类型对象的用法与普通对象没有什么区别:

Console.WriteLine("Amount:{0},Message:{1}",v.Amount,v.Message);

上述代码输出的结果是:

Amount:108,Message:Hello

等一下,这个例子好像违背了面向对象编程的基本原则了,没有定义类怎么就可以创建对象呢?

其实一切都是C#编译器在后面玩的魔术。

使用ildasm工具查看示例程序生成的程序集(UseAnonymousType.exe),可以看到有一个名字非常奇怪的类型生成(见图1-13)。

图1-13 C#编译器为匿名对象生成的“匿名类型”

再打开Main方法对应的IL代码,一切都真相大白。

原来C#编译器动态创建了一个类型(它的名字如图 1-13所示),它的构造函数包括Amount和Message两个参数。Main方法中的变量v被设置为此类型的局部变量。

Main方法先使用“108”和“Hello”作为实参调用此类型的构造函数创建对象,然后让变量v引用这个创建好了的对象。

读取v对象两个字段Amount和Message的值,是通过直接调用匿名类型所定义的get_Amount方法和get_Message方法实现的。

所以,C#的匿名类型特性并未违反“先定义好类再创建对象”这一面向对象编程原则

对于匿名类型对象比较有趣的是我们可以写出这样的代码:

var v=new { Amount=108,Message="Hello"};
Console.WriteLine(v);

上述代码输出:

{ Amount=108,Message=Hello }

这一运行结果引发出一连串的问题:

1)这里输出的结果是从哪儿来的?

2)Console.WriteLine怎么可以直接接收一个临时创建出来的匿名类型对象v?它怎么知道这个对象有几个字段?

如果读者掌握了面向对象的基础知识,并且会使用ildasm和Reflector这两个工具,那么解释这个现象一点也不难。

首先,从Main方法生成的IL代码中可以知道,“Console.WriteLine(v);”实际上调用的是Console.WriteLine方法的以下重载4.2节将更深入地讨论方法重载。形式:

public static void WriteLine(object value);

现在使用Reflector工具查看Console.WriteLine(object)方法的实现代码,会发现上述方法在内部调用参数value的ToString方法生成一个字符串,然后再输出此字符串。

现在回到ildasm,找到C#编译器生成的匿名类型中的ToString方法,打开它,看看里面的IL代码,就知道为何示例会得到那样的结果了,这个工作留给读者作为练习。

现在剩余最后两个问题,涉及C#为何要引入这样“隐晦”的语法特性:

1)为什么不直接指定数据类型而要使用隐式类型来定义变量?

2)匿名类型的对象到底有什么实际用途?

回答是:

隐式类型变量和匿名类型主要用于LINQ本书第11章介绍LINQ。中。

请看以下LINQ to SQLLINQ to SQL是LINQ技术的一个分支,程序员可以在C#代码中嵌入LINQ查询语句,直接从SQL Server中提取数据并处理。深入介绍LINQ to SQL超出了本书的范围,请读者自行阅读相关的技术书籍。代码:

//从SQL Server数据库中提取产品信息
var productQuery=
    from prod in products
    select new { prod.Color,prod.Price };
//显示找到的产品信息
foreach(var v in productQuery)
    Console.WriteLine("Color={0},Price={1}",v.Color,v.Price);

上述代码使用了匿名类型对象来生成数据库查询的结果。隐式类型的局部变量productQuery的真实类型为“IEnumerable<编译器自动生成的自定义匿名类型>”,如果不使用C#的隐式类型变量和匿名类型特性,编写同样功能的代码会变得比较麻烦——因为现在必须“显式”定义一个用于封装查询结果(Color和Price)的类型。