撰写了文章 更新于 2020-02-02 22:42:32
从C#开始的编程入门——开始面向对象
对象国家包分配终究还是只存在于虚拟作品之中,还得靠自己。
尽管很多人嘲笑程序员没有对象(当然我确实也没有),但是绝大部分程序员还是明白面向对象编程的基础理论的。
想象一下,假设我们现在是一家游戏店的老板,不过我们首先得放下对奸商的刻板印象,我们想象自己是大型国际游戏零售商的老板。
那显然呐,我们要把游戏信息和销售情况数字化保存啊。
我们得想想,需要保存一个游戏的那些信息。 标题,肯定的。然后价格、平台、开发商、发行商等等。
行吧,先定义几个变量。针对这几种数据,我们选择不同的类型。这里加上一个ID,方便我们按照Id来索引游戏。由于这类编号可能不止包含数字,而且可能有一些特殊格式,我们暂时用字符串处理。
string id = “JPN114514”;
string title = “Aniki Select”;
float price = 39.99f;
string developer = “Aniki Project”;
string publishier = “Bili Games”;
string platform = “PS4”;
看起来挺不错的。 但是,我们要卖的有成千上万个游戏,难道也要声明成千上万组这样的变量?而且一个游戏就需要这么多变量,这也太怪了。就没有一种叫Game的类型可以专门用来保存游戏的信息吗?
答案是:没有。但是,可以有。
编程语言的内置类型可能对于解决问题是不够的,而且问题中的很多东西并不可能只用一种内置类型就能描述它的全貌。除了游戏信息,还有像个人身份信息这类数据都是这样。上面针对保存游戏所需的数据的思考过程,我们通常称为建模(modeling),也可以称为抽象。这种由问题中的某种东西抽象而成的数据类型,我们通常称为数据模型(data model)。
结构
如果系统没有内置这种类型,那么我们就自己造一个。
结构(struct,又称结构体)是一种允许我们自己描述其所包含的属性的类型的统称。我们来创造一个Game类型。
创建一个新的文件,以Game.cs命名(cs是C#源文件的后缀名,是CSharp的缩写)。
public struct Game
{
public string id;
public string title;
public float price;
public string developer;
public string publisher;
public string platform;
}
结构的定义以struct关键字开始,然后是结构名称,我们可以自己取。然后是一对大括号,里面就是我们自己想要包含的各种数据。就像声明一个变量那样。
那么这里的public是什么意思呢?
字段
在结构的定义中,这些“变量”叫做字段(field),他们是具体描述我们的数据结构的某一个属性的。
当我们定义字段时,需要为其制定访问权限。对于结构来说,访问权限可以是public和private。顾名思义,public是可以在结构外被访问的,private则不能。
除了字段,我们给结构本身也应用了public以使得我们定义的这个结构可以被其他源文件访问。
在不加任何访问修饰符时(Access Modifier),默认为private。 Java程序员注意,C#不使用访问修饰符时默认为private。
注意:结构无法在声明字段时为其立即赋值。
实例化
当我们定义了一个结构,我们相当于在描述一种新的数据,结构是这种数据的模版。虽然我们的游戏都有这些数据,但是游戏与游戏之间,还是不一样的。
我们依据数据的模版,创造一个具体的数据的过程,称作实例化(instantiation),实例化的结果就是,我们得到了一个Game的实例,也即是一个对象。
对象和实例:对象(object)和实例(instance)之间存在一些微妙的区别。但是大部分时候可以互换使用。实例通常指的是从某一个类产生的具体的对象(注意这里的类是概念上的,而不是指某一种编程语言中的class),而对象通常是泛指。
我们可以像声明变量那样声明一个新的结构实例。然后我们使用new Game()这个表达式给它赋值。虽然很抱歉,但这里不得不说至少在这一小节“你知道就行了”这句话,我们稍后详解这个new是什么意思。
Game game = new Game();
成员访问
现在我们有了一个Game实例,我们准备录入游戏的数据了。我们定义了各种字段,要录入信息,我就需要给它们设置值。
game.title = “Aniki Select”;
game.price = 39.99f;
这个句点(.),就叫做成员访问运算符(member access operator)。它也是二元运算符,左边是具体的对象,右边是成员的名称。字段就是结构成员的一种。
注意⚠️ 虽然加减乘除一类的运算符可以在操作数前后加空白,但是成员访问运算符是不行的。
对于公有字段,我们可以使用这种简便的写法,在new后面直接赋值。
Game game = new Game(){title = “Aniki Select”};
就这样,我们有了一个游戏的信息。
现在用同样的方式,我们可以拿到游戏的相关信息,然后留作他用。
Console.WriteLine(game.title);
成员的可访问性
回忆前面所说的public和private。在使用中文的时候,我们通常称public的成员为“公有成员”,private为“私有成员”。 在本节前面的例子中,所有的字段都是公有的,我们可以直接进行读写操作。如果我们声明一个私有成员: private string owner; 再尝试在对象**问这个字段,则会提示错误。 game.owner = “Fuyuko”; 错误提示为“‘Game.owner’ is inaccessible due to its protection level”。意思是由于其保护级别无法访问。 一个结构(类)的成员并不是所有成员都应该、都需要暴露给外界,很显然,作为老板,我们可能不太愿意告诉顾客商品的进货价。因此像这样的保护在很多时候是需要且必要的。
方法
游戏经常会打折,我们需要重新设置其价格。比如说喜闻乐见的-75%。
game.price *= 1f - 0.75f;
这不太好,我们每次都要这样写。我们写一个函数。
Game Discount(Game game, float percentage)
{
game.price *= 1f - percentage;
return game;
}
这样好多了,每次打折我们只需要调用函数传入实例和折扣就行了。操作完成后返回的实例就是打折完毕的了。
但是,每次打折我们都需要这样手动传入一个游戏实例,然后写折扣力度,但是万一我们手滑传入一个错误的游戏,给错误的游戏打骨折了呢???我们如何保证某个函数在某个特定的实例上执行呢?
答案是使用方法(method)。
方法就是一个函数,但是它在结构的定义内部被定义。除了普通函数的能力,它有一个特点就是可以知晓在哪个实例上调用了这个函数,方法中也可以访问实例的所有字段。从现在起,这样的函数就叫方法。
和定义之前所说的函数一样,它需要返回值、参数等等。和字段一样,方法也需要控制访问权限。由于我们需要从外部设置折扣,所以这里为public。
public void Discount(float percentage)
{
this.price *= 1f - percentage;
}
真正让方法了不起的就是,这个this。
this顾名思义,它代表的就是本对象、本实例。在定义方法时,这个this究竟是谁,尚不明确,唯一确定的是,它是一个Game实例,方法会在某个Game实例上调用。上面的叙述中中,“在谁身上调用”这个问题,涉及到的就是名称绑定(name binding)的问题。
this会在运行时被绑定到调用方法的对象上,也就是某个对象调用了某个方法,执行时this就会指向那个对象,获取那个对象的字段以及其他方法。 比如写一个方法,简单输出游戏标题。
public void PrintTitle()
{
System.Console.WriteLine(title);
}
结果会正确输出不同的标题。
在C#中,在没有歧义的情况下(有歧义的情况比如说就是,方法的某个参数名和某个字段名称相同),可以省略this。
构造函数
首先要记得,结构的字段不能在声明时马上赋值。 那如果每次实例化一个游戏都要这样一个个设置各种也太麻烦了。
这个时候我们需要编写符合我们需求的构造函数(constructor,亦称构造器)。
什么是new?new了什么?
我们先来看之前留空的这句: Game game = new Game(); new也是一个运算符,不过它没有其他名字,它就叫new运算符。它的作用是创建一个类型的实例。
由于结构是我们自定义的一种复合类型,在运行时准备为我们的Game对象分配内存空间时,还要考虑Game中的各种字段和方法等等。我们需要通过new来告诉运行时我们需要创建一个新的实例,而无需我们自己考虑成员如何分配。
C++中也有new,new会返回一个指针。C#的new在作用上基本可以看作是类似的。但是C#中在绝大多数情况下不使用指针,对于结构来说,new返回的是一个实例,而不是指向实例的一个指针。
对于结构(以及类)来说,new后面紧接着的是构造函数调用(constructor invocation)。
构造函数本身就是一个方法(并且通常是公有方法),但是它有一些特殊之处。我们首先来看一个构造函数的声明。
首先以public开头,紧接着就是方法名称,结构的构造函数的名称必须是结构的名称(在本例中意味着必须是Game),然后和普通方法一样,是括号和参数列表。
public Game(string id, float price, string title = “”, string platform = “”, string developer = “”, string publisher = “”)
{
this.id = id;
owner = “Fuyuko”;
this.platform = platform;
this.price = price;
this.developer = developer;
this.title = title;
this.publisher = publisher;
}
说到这里一个构造函数的签名就算完了,您可能注意到了,构造函数的声明中没有返回类型,并且不允许使用return返回任何值。new运算符会和构造函数的调用一起使用,这样一个表达式的值是一个对应类型的实例,我们可以认为构造函数的返回值就是一个对应类型的实例。
回忆前面我们用到的无参数的构造函数,也就是Game(),你会发现我们并没有定义这样一个方法。对于结构来说,每种结构都会自带一个默认构造函数,也就是这个无参数版本的构造函数。另外,我们无法显式地定义一个默认构造函数。在默认构造函数中,各种字段会被进行默认初始化。
使用构造函数初始化实例
构造函数无法直接像方法一样调用(因为实际上在调用构造函数的时候我们的实例还没有构造完毕),必须使用new关键字进行调用。使用我们前面定义的构造函数(注意参数默认值的使用)。 Game anotherGame = new Game(“JPN114514”, 39.99f, “Aniki Wars”); 构造函数的真正作用就是在拿到一个实例之前,根据我们的需要初始化实例的各个字段,以及一些额外的操作。
字段必须被全部初始化
一旦我们想要定义一个有参数的构造函数,意味着我们不会使用默认构造函数对字段进行默认初始化,因此会要求我们对所有字段进行完整的初始化,以防我们错误地使用了某个没有被初始化的字段。如果你尝试删除上一小节的构造函数中的任意一行,你都会收到错误提示:Field ‘Game.publisher’ must be fully assigned before control is returned to the caller(在控制返回给调用者前,字段必须被完全初始化)。
属性
在保存数据时,我们时常会遇到一些很敏感的数据,一些数据可能可以给外界展示,但是并不能随意更改。如果我们定义了一个代表库存的stock字段: private uint stock; 让顾客知道库存多少,并不是什么有问题的事情,并且很多时候可能还很有必要。 如果我们让它成为私有字段,那么外界并不能获得这个字段,但是如果是公有字段,外界又可以随意更改,库存可不是能随便改的。这该怎么办呢?
我们可以使用一个方法,直接返回库存。
public uint GetStock()
{
return stock;
}
同样的道理,如果确实需要设置库存,我们可以在定义一个SetStock方法。
public void SetStock(uint stock)
{
this.stock = stock;
}
并且,方法中还可以进行额外的检查,感觉挺好的,一切都如此完美。
但是这种写法并不那么C#,如果有十多个这样的字段需要这样的读写操作,就需要二三十个这样成对的只有一句话的方法。
在C#中,一种更好的做法是使用属性(property)来处理这种字段。下面是Stock属性的定义,它和上面写的两个方法在作用上是等效的。
public uint Stock
{
get { return stock; }
set { stock = value; }
}
初见属性的定义,你可能很奇怪,你如果把它当成字段看,它后面却跟着一个代码块;如果当成方法看,它却没有参数列表。其实,属性本身就像是字段和方法的结合。
首先属性和字段一样,有它的类型和名称。但是定义属性的代码块中,包含了两个部分,一个get,一个set。实际上,这就分别对应了上面的GetStock和SetStock方法。也就是说get和set实际上就是两个方法,这两个方法控制着属性的存取。这一对方法被称为属性访问器(property accessor)。
get访问器
get必须返回属性声明中的类型的值。这里我们返回的就是隐藏的私有字段stock。
get { return stock; }
set访问器
set的作用是设置stock字段的值。这里注意,value是一个关键字,value在set中出现时,它代表着赋值给这个属性的值。
set { stock = value; }
访问属性
前面我有提到,属性本身就像是字段和方法的结合。这一点在访问属性时,你便能够更好地理解。
System.Console.WriteLine(game.Stock);
我们像上面这样获得属性的值,它的表现就像是一个公有字段一样。这个时候我们实际上就是调用了get。如果你在get中加上一条输出内容的语句,你就能看到相应的输出,证明我们此时实际上调用了get访问器。
在设置属性的值时,我们同样像是在设置一个公有属性一样。如果有需要,我们也可以在set访问器的定义中更严格地控制字段值的设置,比如设置一个库存上限等等。 game.Stock += 1;
自动实现的属性
对于我们的stock来说,这个字段实际上作用比较单一,它只被我们的属性使用和管理,这个时候我们甚至不需要显式声明这样一个字段,直接删掉私有的stock字段,然后直接把属性简写成这样:
public uint Stock { set; get; }
属性的功能和操作一切照旧,并且我们的代码更加简洁。这样的属性称为自动实现的属性(auto-implemented property,微软语言门户指导翻译为自动实现的属性,但这太拗口了,以后简称自动属性)。实际上自动属性背后也有一个隐藏的字段,只不过不需要我们显式声明了。
只读属性
如果删掉set访问器的定义,属性就变成了只读属性。但是! 只读属性并不能使用自动属性声明,道理很简单,如果是只读的属性,那么编译器并不知道当获取属性值的时候需要返回什么,它也就无法帮我们生成一个隐藏的字段。
public uint Stock { private set; get; }
你也可以把get删掉留下set,属性便成了只写属性,这种情况相对来说比较少见。同样的道理,只写属性也不能作为自动属性。
属性访问器的访问权限
你可能记得我说库存不能随便设置,但是你想用自动属性,又不想给外界提供写入的权限怎么办呢?至少我们自己应该有设置库存的权限。这个时候也可以对访问器使用访问修饰符。
这样一来,只能在结构内部对Stock属性进行写入,而外界只能通过get访问器读取Stock。 public uint Stock { private set; get; }
属性实际上就是一个名字加两个方法,只不过我们可以像使用字段那样访问一个通过方法保护的字段。
封装
本篇文章着重讲了结构的使用。实际上,我们这样一个结构的定义就体现了面向对象编程中的一个特性——封装(encapsulation)。 首先,我们把一个对象的各种数据放在了一个类型中,在我们的例子中具体来说就是游戏的标题、价格等字段。 然后我们定义了和对象本身息息相关的各种方法。方法是用来操作对象的函数,我们通过方法来改变对象的状态,让对象完成我们指定的工作。 最终,在我们构造出一个新的对象时,我们的对象既包含它自身的数据,还有用于操作对象的方法,使得我们不需要定义大量乱七八糟的变量和方法。 另一方面,C#的属性帮助我们封装了字段。属性提供了一种在不破坏封装的情况下能够更加灵活地访问数据的方式。
关于面向对象,这一篇文章仅仅是个开始,后续文章讲逐步深入。
现在你已经是能够面向对象的程序员了! 下一篇,我们重新审视一下我们接触过的结构,你可能会想,“不是这节才开始讲结构吗?前面哪里有结构?难道是我错过了一集?”,但真相可能会让你感到惊讶。
目录