本章介绍C#面向对象编程的基础知识,重点在于如何定义类,可将类理解成对象的模板。之前学过的所有结构化编程构造仍然适用,但将那些构造封装在类中,可以创建更大、更有条理以及更容易维护的程序。
从结构化、基于控制流程的程序转向面向对象的程序,是因为面向对象编程提供了一个额外的组织层次,结果是较小的程序在某种程度上得到了简化。 更容易创建较大的程序,因为程序中的代码得到了更好的组织。 面向对象编程的一个关键优势是不必从头创建新程序,而是可以将现有的一些列对象装到一起,用新功能扩展类或者添加更多的类。
初学者主题:面向对象编程(OOP)
成功编程的关键在于提供恰当的组织和结构,以满足大型应用程序的复杂需求。 面向对象编程最基本的构造是类。一组内构成了编程抽象、模型或模板,筒仓对应现实世界的一个概念。 类是面向对象编程的三个主要特征–封装、继承和多态性
封装
封装旨在隐藏细节,必要的时候细节仍可访问,但通过巧妙地封装细节。大的程序变得更容易理解,数据不会因为不慎而被修改,代码也变得更容易维护。方法就是封装的一个例子。
继承
例如:硬盘、U盘、软盘都是存储媒体,但分别具有不同地特征。 面向对象编程中地继承允许在这些相似但又不同的物件之间建立属于关系。 为上面提到地每种存储媒体类型都定义一个类,就得到了类层次结构。它由一系列“属于”关系构成。 为了从一个类型派生或继承,需对类型进行特化。这意味着需要对基类型进行自定义为满足特定需求而调整它。基类型可能包含所有派生类型都适用地实现细节。 继承最关键地一点是所有派生类都继承基类型地成员。派生类中可以修改基类型的成员。但无论如何,派生类型除了自己显式添加的成员,还包含了基类型的成员。
多态性
讲到对象时,多态性意味着一个方法或类型可具有多种形式的实现。假定一个某提播放机,技能播放音乐CD,也能播放MP3歌曲的DVD,但play()方法的具体实现会拖着媒体类型的变化而变化。多态性使不同类型能自己照料一个方法的实现细节,因为多个派生类型都包含了该方法,每个派生类型都共享同一个基类型,后者也包含相同的方法签名。
定义类首先指定关键字class后跟一个标识符。
设计规范
*不要再一个源代码文件中放多个类。 *要用所含公共类型的名称命名源代码文件。
初学者主题:对象和类
非正式场合中,类和对象这两个词经常互换着适用,但对象和类具有截然不同的含义。类是模板,定义了对象实例化时看起来像什么样子。所以对象是类的实例。类就像是模具,对象是采用了这个模具创建的零件,从类创建对象的过程称为实例化。
因为对象是类的实例。 C#适用new关键字实例化对象。 声明和赋值既能在同一行完成,也能分行完成。 虽然有专门的new操作符分配内存,但没有对应的操作符回收内存,相反,”运行时“会在对象变得不可访问之后的某个时间自动回收内存。具体是由垃圾回收期回收。它判断那个对象不再由其他活动对象引用,然后安排一个时间回收对象占用的内存。
在面向对象术语中,在类中存储数据的变量称为==成员变量。这个属于在C#中很好理解,但更符合规范的术语是字段==。它是与包容类型关联的具名存储单元。实例字段是在类的级别上声明的变量,用于存储与对象实例关联的数据。
实例字段也就是成员变量。
可设置和获取字段中的数据,注意字段不包含static修饰符,意味着它是实例字段。只能从其包容类的实例对象中访问实例字段,无法直接从类中直接访问(换言之,不创建实例就不能访问)
例如:把员工姓名相关的方法,放到包含姓名数据的类中。
可在类的实例成员内部获取对该类的引用。C#允许用关键字this显式指出当前访问字段或方法是包容类的实例成员。调用任何实例成员时this都是隐含的。它返回对象本身的实例。
初学者主题:依靠编码样式避免歧义
使用this避免歧义
五个访问修饰符:如public、private、protected、internal和protected internal
初学者主题:封装——信息隐藏
除了组合数据和方法,封装的另一个重要作用是隐藏对象的数据和行为的内部细节。 方法在某种程度也能做到这一点:在方法外部,调用者看见的只有方法的声明,看不见内部实现,但面向对象编程更进一步,它能控制类成员在类外部的可视程度。类外部不可见的成员称为私有成员。 访问修饰符的作用是提供封装。public显式指明可从类的外部访问被它修饰的字段,例如,可以从Program类中访问那些字段。 为隐藏Password字段,禁止从它的包容类的外部访问,应使用private,使用一个方法来返回Password。
private禁止从类的外部访问,但这种形式的封装过于严格。例如:可能希望字段在外部只读,但内部可以更改。又例如,可能希望允许对类中的一些数据执行写操作,但需要验证对数据的更改。再例如,可能希望动态的构造数据。
public class Employee
{
public string FirstName
{
get
{
return _Firstname;
}
set
{
_FirstName=value;
}
private string _FirstName;
}
}
属性的关键在于,它提供了从编程角度看类似于字段的API,但事实上并不存在这些字段。属性声明看起来和字段声明一样,但跟随属性名之后的是一对大括号。要在其中添加属性的实现。属性的实现由两个可选的部分构成。其中,get标志属性的取值方法,set标志属性的赋值方法,它实现了字段的赋值语法。 赋值方法可用value关键字引用赋值操作的右侧部分。
public string? Title {get;set;}
字段初始化:
public string? Salary {get;set;}="Not Enough";
简化了写法,也使代码更易读,此外,如未来需添加一些额外代码,比如要在赋值方法中进行验证,那么虽然要修改现在的属性声明来包含实现,但调用它们的代码不必进行任何修改。
由于可以写显式的赋值和取值方法而不是属性,所有有时会疑惑改用属性还是方法。一般原则是方法代表行动,而属性代表数据。属性旨在简化对简单数据的访问,调用属性的代价不应该比访问字段高出太多。
属性采用_ PascalCase,其他常见的由_ camelCase和m_PascalCase.另外为了符合封装原则,属性的支持字段不应声明为public或protected
设计规范
•使用属性简化对简单数据的访问
•避免从数学取值方法中抛出异常
•要在属性抛出异常时保留原始属性值
•如果不需要额外逻辑,优先使用自动实现的属性,而不是属性加简单支持字段。 属性和类型同名的情况并不罕见
设计规范
•考虑为支持字段和属性使用相同的大小写风格,为支持字段附加==“_”前缀==,但不要使用双下划线,它是为C#编译器保留的。
•要使用名词、名词短语或形容词命名属性
•考虑让某个属性和类型同名
•避免使用camelCase大小写风格命名字段。
•如果由意义的话,给Boolean 属性附加Is、Can、Has前缀
•不要声明public或protected实例字段
•要优先使用自动实现的属性而不是字段。
•如果没有额外的实现逻辑,要优先使用自动实行的属性而不是自己写完整版本。
使用属性而不是字段进行赋值的结果是,无论是在类的外部还是内部,属性的赋值方法中的任何验证都会得到调用。 在新实现中,如果赋了无效的值,代码就会抛出异常,拦截赋值,并通过字段风格的API对参数进行验证,这是属性的优点之一。 虽然少见,但却是能在赋值方法中,对value进行赋值。
// Non-nullable field is uninitialized. Consider declaring as nullable.
#pragma warning disable CS8618
using System;
namespace AddisonWesley.Michaelis.EssentialCSharp.Chapter06.Listing06_20
{
public class Employee
{
// ...
public void Initialize(
string newFirstName, string newLastName)
{
// Use property inside the Employee
// class as well
FirstName = newFirstName;
LastName = newLastName;
}
// LastName property
public string LastName
{
get => _LastName;
set
{
// Validate LastName assignment
if(value == null)
{
// Report error
throw new ArgumentNullException(nameof(value));
}
else
{
// Remove any whitespace around
// the new last name
value = value.Trim();
if(value == "")
{
throw new ArgumentException(
"LastName cannot be blank.", nameof(value));
}
else
{
_LastName = value;
}
}
}
}
private string _LastName;
// FirstName property
public string FirstName
{
get
{
return _FirstName;
}
set
{
// Validate FirstName assignment
if(value == null)
{
// Report error
// In C# 6.0 replace "value" with nameof(value)
throw new ArgumentNullException("value");
}
else
{
// Remove any whitespace around
// the new last name
value = value.Trim();
if(value == "")
{
throw new ArgumentException(
// Use "value" rather than nameof(value)
// prior to C# 6.0.
"FirstName cannot be blank.", nameof(value));
}
else
{
_FirstName = value;
}
}
}
}
private string _FirstName;
}
}
设计规范
•避免从属性外部(即使是从属性所在的类中)访问属性的支持字段。
•创建ArgumentException()或ArgumentNullException()类型的异常时,要为paraName参数传递“value”,它是属性数值方法中隐含的参数名。
高级主题:nameof操作符
属性验证时如判断新赋值无效,就需要抛出ArgumentException()或Argument-NullException()类型的异常。两个异常都获取string类型的实参paramName来标识无效参数的名称。
但从C# 6.0起可用nameof操作符来改进。该操作符获取一个标识符(比如value变量)作为参数,返回该名称的字符串形式(本例是"value")。以上代码在报告第二个错误时也使用了nameof的方法。
通过移除属性的取值方法或赋值方法,可以改变属性的可访问性。只有赋值方法的属性是只写属性,这种情况是罕见地。类似地,只提供取值方法会得到只读属性,任何赋值企图都会编译错误。 只读自动实现属性的一个重点在,和只读字段一样,编译器要求通过初始化器或构造函数来初始化。 由于规范是不要从属性外部访问支持字段,所以在C#6.0之后,几乎永远用不着之前的语法,相反,应总是使用只读自动实现属性。唯一例外是在字段和属性不匹配的时候。
只写属性:
public int Num1 {set;}
只读属性:
public int Num2 {get;}
设计规范
•如属性值不变,创建只读属性
•如属性值不变,从C#6.0起要创建只读自动实现的属性而不是只读属性加支持字段。
Name属性的取值方法连接FirstName和LastName属性的返回值。事实上,所赋的姓名并没有真正的存储下来。向Name属性赋值时,右侧的值会解析成名字和姓氏部分。
为赋值方法指定private修饰符,属性对于除Employee的其他类是只读的,在Employee类内部,属性可读且可写。所以可在构造函数中对属性进行赋值。为取值或赋值方法指定访问修饰符时,注意该访问修饰符的“限制性”必须比应用于整个属性的访问修饰符更“严格”。例如,属性声明为严格的private,将它的赋值方法声明为宽松的public,就会发生编译错误。
设计规范
•要为所有属性的取值和赋值方法应用适当的可访问性修饰符
•不要提供只写属性,也不要让赋值方法的可访问性比取值方法更宽松
C#允许属性像字段那样使用,只是不允许作为ref或out参数值传递。ref和out参数内部都要将内存地址传给目标方法。但由于属性可能是无支持字段的虚字段,也有可能时只读或只写,所以不可能传递存储位置。同样的道理也适用于方法调用。
高级主题:属性的内部工作机制
除外观与普通方法无异,注意属性在CIL中也是一种显式的构造。
现在已为类添加用于存储数据的字段,接着应考虑数据的有效性。可用new操作符实例化对象,但这样可能创建无效的员工对象。 实例化后得到的是尚未初始化的对象。假如忘了初始化,编译器也不会发出警告,结果是得到含有无效姓名的Employee对象。
为解决问题,必须提供一种方式在创建对象时指定必要的数据。这是构造函数来实现的。 定义构造函数需创建一个五返回类型的方法,方法名必须和类型完全一样。 构造函数是“运行时”用来初始化对象实例的方法。 开发者应注意既在声明中又在构造函数中赋值的情况。如字段在声明时赋值,那么只有在这个赋值发生后,构造函数内部的赋值才会发生。所以最后生效的是构造函数内部的赋值,所以有必要考虑一种编码风格,避免同一个类中既在声明时赋值、又在构造函数中赋值。
高级主题:new操作符的实现细节
new操作符内部和构造函数是像下面这样交互的。new操作符从内存管理器获取“空白”内存,调用指定构造函数,将对“空白内存”的引用作为隐式的this参数传给构造函数。 构造函数链上的执行结束之后,new操作符返回内存引用,现在,该引用指向的内存处于初始化好的形式。
必须注意,一旦显式的添加了构造函数,在Main()中实例化Employee就必须指定名字和形式。 没有显式定义的构造函数,C#编译器会在编译时自动添加一个。该构造函数不获取参数,称为默认构造函数 显式添加了构造函数,C#编译器不再自动提供默认构造函数。
C#3.0新增了对象初始化器,用于初始化对象所有可以访问的字段和属性,调用构造函数创建对象时,可在后面的一对大括号中添加成员初始化列表. 每个成员的初始化操作都是一个赋值操作.
设计规范
•要为所有属性提供有意义的默认值,确保默认值不会造成安全漏洞或造成代码执行效率大幅下降.自动实现的属性通过构造函数设置默认值. *要也允许属性以任意顺序设置,即使这会造成对象暂时处于无效状态。
高级主题:集合初始化器
C#3.0还增加了集合初始化器,采用和对象初始化器相似的语法,用于在集合实例化期间向集合项赋值。
高级主题:终结器
构造函数定义了在类的实例化过程中发生的事情。为定义在对象销毁过程中发生的事情,C#提供了终结器。和C++的析构器不同,终结器不是在对一个对象的所有引用都消失后马上运行。相反,终结器是在对象被判定“不可到达”之后的不确定时间内执行。具体地说,垃圾回收器会在一次垃圾回收过程中识别出带有终结器的对象。但不是立即回收这些对象,而是将它们添加到一个终结队列中。一个独立的线程遍历终结队列中的每一个对象,调用其终结器,然后将其从队列中删除,使其再次可供垃圾回收器处理。
只要参数类型和类型有区别,可同时存在多个构造函数。
设计规范
•如构造函数的参数只是用于设计属性,构造函数参数要使用和属性相同的名称,PascalCase,区别仅仅是首字母的大小写。
•要为构造函数提供可选参数,并且提供便利的重载构造函数,用好的默认值初始化属性。
构造函数允许获得多个参数并把它们全部封装到一个对象中,但在C#7.0之前没有一个现实的语言构造来做相反的事情,即把封装好的项拆分为它的各个组成部分。
public static int NextId;
使用static关键字定义能由多个实例共享的数据。所有实例都共享一个NextId存储位置。 和“实例字段”一样,静态字段也可声明时初始化。每创建一个对象实例,非静态字段(实例字段)都要占用一个新的存储位置。 静态字段从属于类而非实例。因此,是使用类名从类外部访问静态字段。
和静态字段一样,直接在类名后访问静态方法。访问这种方法不需要实例。由于静态方法不通过实例引用,所以this关键字在静态方法中无效。此外,要在静态方法内部直接访问实例字段或实例方法,必须先获得对字段或方法所属的那个实例的引用。
除了静态字段和方法,C#还支持静态构造函数,其作用是将类中的静态数据初始化为特定值,尤其是在无法通过声明时的一次简单赋值来获得初始值时。
高级主题:最好在声明时进行静态初始化(而不要使用静态构造函数)
设计规范
•考虑要么以内联方式初始化静态字段而不要使用静态构造函数,要么在声明时初始化。
属性也可声明为static,使用静态属性几乎肯定比使用公共静态字段好,因为公共静态字段在任何地方都能盗用,而静态属性至少提高了一定程度的封装。 从C#6.0开始,整个NextId含不可访问的字段,都可简化为带初始化器的自动实现属性。 public static int NextId { get;private set;}-42;
有的类不含任何实例字段,创建能实例化的类没有意思,所以用static关键字修饰该类,有两方面的意义,首先,它防止程序员写代码来实例化该类,其次防止在内的内部声明任何实例字段或方法。 静态类的另一个特点是C#编译器自动在CIL代码中把它编辑为abstract和sealed,这会将类指定为不可扩展,换言之,不能从它派生出其他类。
能模拟为其他类创建实例方法,只需要更改静态方法的签名,使第一个参数成为要扩展的类型,并在类型名称前附加this关键字。 该设计允许为任何类添加“实例方法”,即使那些不在同一个程序集中的类。但查看CIL代码,会发现扩展方法是作为普通静态方法调用的,扩展方法的要求如下。
•第一个参数是要扩展或者要操作的类型,成为“被扩展类型”。
•为只当扩展方法,要在扩展的类型名称前附加this修饰符
•为了将方法作为扩展方法访问,要用using指令导入扩展类型的命名空间,或将扩展类型的和调用代码放在同一命名空间。 通过继承来特化类型要优于使用扩展方法,扩展方法无益于建立清楚的版本控制机制。因为一旦在被扩展类型中添加匹配的签名,就会覆盖现有扩展方法,而且不会发出任何警告。 总之,扩展方法要慎用。
设计规范
•避免随便定义扩展方法,尤其是不要为自己无所有权的类型定义。
除了本章前面讨论的属性和访问修饰符,还有其他几种特殊方式可将数据封装到类中,例如还有另外两个字段修饰符:const和readonly。
和const值一样,const字段包含在编译时确定的值,运行时不可修改。常量字段自动成为静态字段,意味着不需要为每个对象实例都生成新的字段实例。常量字段通常只声明有字面值的类型string,int,double,System.Guid则不能用于常量字段。
设计规范
•要为永远不变的值使用常量字段
•不要为将来会发生变化的值使用常量字段。
高级主题:public 常量应该是恒定值
public 常量应恒定不变,因为如果修改它,在使用他的程序集中不一定能反映出最新改变,如一个程序集引用了另一个程序集中的常量, 将来可能改变的值应指定为readonly,不要指定为常量。
和const不同,readonly 修饰符只能用于字段不能用于局部变量,它指出字段值只能从构造函数中更改,声明时通过初始化器指定。 和const不同,每个实例的readonly字段值都可以不同。事实上,readonly字段的值可在构造函数中更改。此外,readonly字段可以是实例或静态字段。
另一个关键区别是,可在执行时为readonly赋值,而非只能在编译时。由于readonly字段必须通过构造函数或初始化器来设置,所以编译器要求这种字段能从其属性外部访问。但除此之外,不要从属性外部访问属性的支持字段。
和const相比,readonly字段的另一个重要特点是不限于字面值的类型,例如可声明 readonly System.Guid实例字段: 声明为常量则不行,因为没有GUID的C#字面值形式。 由于规范要求字段不要从其包容属性外部访问,所以从C#6.0起readonly修饰符几乎完全没有用武之处,相反,总是选择前面讨论的只读自动实现属性就可以了。 换言之,向数组施加只读限制,不会冻结数组的内容,相反,它只是冻结数组实例以及数组中的元素数量,因为不可能重新复制来指向一个新的数组实例。数组中的元素仍然可写。
设计规范
•从C#6.0开始,要优先选择只读自动实现的属性而不是只读字段。 *在C#6.0开始,要为预定义对象实例使用public static readonly字段
•如要求版本API兼容性,在C#6.0或更高版本中,避免将C#6.0之前的public readonly字段修改成只读自动实现属性。
类中还可以定义一个类,这称为嵌套类,假如一个类在它的包容器外部没有多大意义,就适合设计成嵌套类。 嵌套类的独特之处是可以为类自身指定private访问修饰符,由于类的作用是解析命令行,并将每个实参放到单独字段中,所以他在该应用程序之和Program类有关系。使用private访问修饰符可限定类的作用域,防止从类的外部访问。只有嵌套类这么做。 嵌套类中的this成员引用嵌套类而非包容类的实例。嵌套类要访问包容类的实例,一个方案是显示传递包容类的实例。比如通过构造函数或方法参数。嵌套类另一个有趣的地方在于它能访问包容类的任何成员,其中包括私有成员。反之则不然,包容类不能访问嵌套类的私有成员。 嵌套类用得很少,要从包容类型外部引用,就不能定义嵌套类。另外要警惕public嵌套类,它们意味着不良的编码风格。
设计规范
•避免声明公共嵌套类型。少数高级自定义场景才需考虑。
分布类是一个类的多个部分,编译器可把它们合成一个完整的类。虽然可在同一个文件中定义两个或更多分布类,但分布类的目的就是将一个类的定义划分到多个文件中,这对生成或修改代码的工具尤其有用,通过分布类,由工具处理的文件可独立于开发者手动编码的文件。
使用class前的上下关键字partial来声明分布类。 不允许用分布类扩展编译好的类或其他程序集的类,只能利用分布类在同一程序集中将一个类的实现拆分成多个文件。
只能存在于分布类中,和分布类类似,作用是为代码生成提供方便。 必须返回void