撰写了文章 更新于 2020-04-20 16:21:21
从C#开始的编程入门——类
本篇我们介绍C#中的类(class)。
再次强调,这里的类是C#中的类,结构本身来说也具备概念和理论上的类的一些特征。
回顾讲解结构时游戏的概念。
这样的代码会生成两个游戏的实例。并且修改其中一个游戏的信息,不会影响另一个。这样的行为,并不符合我们的需求。
Game game = new Game(){title = “Aniki Select”}; Game newGame = game;
首先,一款游戏的信息应当是唯一的。我们保存的游戏信息是针对某一款游戏的,而不是一张游戏卡带或者光盘。既然两款游戏是同一个游戏,那么相关信息应该也是一样的,不可能说同一时间同一款游戏有不同的库存和标题(别杠什么不同版本啦)。并且,这种实例的复制,是有性能消耗的,如果我们不需要两份不同的信息,那么为什么同样的实例我们要创造两份?
类的实例会帮我们解决这个问题。
定义
我们来把之前的游戏结构改写为类。且看改写后的Game结构,现在它是一个类。
public class Game { public string Id { private set; get; } public string Title { set; get; } public float Price { set; get; } public string Developer { set; get; } public string Publisher { set; get; } public uint Stock { set; get; } public void Discount(float percentage) { this.Price *= 1f - percentage; } }
确实,除了class关键字代替了struct关键字之外,几乎没有任何区别(不过我把字段都该写成了属性,但为了方便几乎所有属性的set访问器都是公开的)。结构支持定义的成员,类也支持。(这里暂时没有定义构造函数,使用默认构造函数构造实例)。
引用
回到前面的例子,我们现在用类来进行同样的操作。
Game game = new Game(){title = “Aniki Select”}; Game newGame = game; newGamae.Title = “Aniki Fighters”; Console.WriteLine(game.Title);
你会发现,两个Game类型的变量在发生赋值后开始相互影响。
产生这样的效果的原因是,结构是值类型,而类是引用类型。
堆和栈
计算机执行程序的时候所使用的内存会被划分为堆(heap)和栈(stack)。
在抽象上,栈是一种先进后出(first in last out)的数据结构。就像一摞书一样,我们一本一本地往上摞,然后一本一本往下拿,最先放的书会最后被拿出来。
在执行方法时,方法中的参数和本地变量都会被推入(push)栈中。而方法迟早会返回调用它的上一个方法,这个时候这些本地变量离开了它们各自的作用域,它们不再被这个即将返回的方法需要,也不能被外部使用,这个时候它们会被弹出(pop)栈,并且把各自的内存归还给栈。它们的生命周期到此结束。
而当我们构造了一个类类型的实例,这个实例会在堆中拿到自己需要的内存。而栈上的类类型变量,并不会保存这个实例本身,而是保存的指向堆中那块内存的内存地址。
保存在栈中的变量被复制时,会有一个同样的值被推入栈中,即便是两个一样的值。 而对于类类型的变量,栈中会多一个变量,唯一被复制的是指向堆中实例的引用,或者说内存地址,而堆中的实例不会被复制,因为一个实例可以被多个变量所引用。即使某个方法结束时,某个类类型变量离开作用域放弃了它的引用,但是这个时候可能有来自其他作用域的变量仍然在引用堆中的这个实例,因此这个实例不一定会把内存归还给堆。这实际上涉及到的是一个非常复杂的课题:垃圾回收(Garbage Collection)。
引用类型和值类型
所有的结构都是值类型,而类是引用类型。值类型的值在一般情况下都会在栈中分配,而引用类型的实例则是在堆中。
当我们声明一个结构类型的变量,它实际上就相当于保存了一个值,而类类型的变量则是引用。它们的行为就会像上面的描述那样。 尝试复制一个指向类实例的引用时,只会发生引用的复制,而不会复制引用指向的实例。因此,两个Game变量实际上都是引用的同一个实例,因此在此后进行修改通过两个变量都能看到影响。
当一个变量是类类型时,它其实就相当于C/C++中的指针,或者C++中的引用。 但在C#中,绝大部分时候不使用C/C++风格的指针,表示类实例的引用和表示值类型的值的变量在声明上没有任何区别,也就是说无论Foo类型是结构还是类,我们声明Foo类型的变量都是Foo foo而不会出现Foo *foo这样的代码。一个值类型的变量就是代表它的值,引用类型的变量就是代表一个指向实例的引用。
实际上你不需要把引用看得那么特殊,引用本身也是一种“值”,它代表一个地址,引用类型作参数时一样按照值传递,不要把引用本身和“按引用传递”搞混了。
null——空引用
和指针一样,引用也会存在没有引用任何对象的时候,这个时候它的值为null,而null代表什么都没有。任何时候当你尝试访问null引用的成员时,都会发生NullReferenceException。
一个类类型的变量在没有被初始化或者赋值时,它什么都没有引用,其值就是null。而有些时候你可以主动把值设置为null,表示它确实没有值或者至少现在没有值。
关于null这样的占位符式的空值,实际上本身就存在很大的争议。其发明者甚至称它是“Billion Dollar Mistake”。因为null本身所代表的意义不够明确。比如说当我们从某个函数拿到为null的返回值时,我们并不清楚是本身就没有值,还是因为某个环节出错而导致没有返回值。
null检查和null条件运算符
由于null的存在,我们时常需要检查类类型变量是否当前引用了一个类实例。
null就像一个特殊值一样,我们可以用==像判断数值相等那样判断引用是否为null。
if(someObj != null) { someObj.SomeMethod(); }
我们常常在检查是否为null之后会尝试调用这个对象的方法,如果这个对象为null则整个表达式的返回值也为null(或者什么也不做)。C#提供了null条件运算符来简化这个if语句。
someObj?.SomeMethod();
成员初始化
对于类来说,不必手动在构造函数中为所有成员初始化。在结构中,如果手动编写了构造函数,则需要在其中为所有成员初始化,并且不能主动声明一个无参数的构造函数,就像我们在结构版的Game中那样。
但是类有些不一样。首先,即使我们写了自己的构造函数,也不用手动为所有字段初始化值。而没被初始化的成员,会被自动初始化为默认值。
另外,对于类,我们可以直接在成员定义处直接初始化,而在结构中是不允许的。
直接初始化成员相当于我们重新给它们设置了默认值,因此如果在构造函数中又一次为这个成员赋值的话,会覆盖掉这个我们设置的默认值。
类中一样可以有其他类类型的成员,对于类类型的成员,如果没有初始化其值为null。
class Foo{} class Bar { public Foo Foo {set; get;} }
这个时候
Bar bar = new Bar(); System.Console.WriteLine(bar.Foo == null);
输出必然为True。
因此如果你的类中存在类类型的成员,一定要明确你是否需要在构造实例时也对这个成员进行初始化。而使用类类型的变量时时常也需要确认它是否为null。
对于结构类型的成员来说,我们可能不需要操那么多心。因为正如前面我们讲解结构的时候所说的,结构首先有默认构造函数,其次即使是手写的构造函数也需要完全初始化所有成员,因此即使不手动初始化结构成员,它也会按照设计者的要求自行初始化自己,别忘了,就像int一类的数值类型那样,这些结构也会自己正常地完成初始化。
继承
对于类来说,和结构另一个重要的区别就是,类支持继承。这是面向对象编程的重要概念之一。
我们重新定义一个类,Animal,用来表示动物的一些特征。
public class Animal { public string Name { set; get;} public int Age { set; get;} public double Height { set; get; } public double Length { set; get; } public void SaySomething() { System.Console.WriteLine(“I’m just an animal.”); } }
现在如果我们要用一个类来表示狗,我们写一个新类。
public class Dog { public string Name { set; get; } public int Age { set; get; } public double Height { set; get; } public double Length { set; get; } public void SaySomething() { System.Console.WriteLine(“Woof! I’m a dog!”); } public void Walk() { System.Console.WriteLine(“Hey, I’m going for a walk.”); } }
我们会发现,除了散步的方法Walk,这两个类有很多相同的成员。如果我们想用一个类来表示雪橇犬,那么雪橇犬类可能有更多的重复成员。
从抽象上来说,狗是动物的一种,是一种更加具体的动物,狗具有动物类的全部特征。
这个时候,我们应当使用继承(inheritance)来表达这两种类的关系,让我们的代码更加面向对象,减少冗余。
现在,Dog类已经被改写为继承自Animal类。在类名后加上一个冒号,后面紧接着是父类的名称。我们称Dog继承自Animal类,Animal是其父类(或称基类),Dog类是其子类(或称派生类)。
public class Dog : Animal { public void Walk() { System.Console.WriteLine(“Hey, I’m going for a walk.”); } }
和C++不一样的是,C#只支持单继承,也就是说一个类只能有一个直接基类。
现在,重复的字段全部不见了。那么我们怎么才能访问父类的成员呢?
protected修饰符
首先,结构中的private和public修饰符在类中依然可以使用,并且在继承中行为依然相同。子类虽然继承自父类,但不管怎么样都是另外一个类,也就是说,父类的private成员在子类中依然无法访问,你可以想想并不是你爸爸的什么东西你都可以碰的。而public可以在类外访问,这里的“类外”包括了除自己以外的所有情况,也包括了子类。比如我们在Dog中添加一个方法:
public void SelfIntroduce() { System.Console.WriteLine($”Hello, I’m a dog named {Name}”); }
Name属性的声明来自父类,在子类中可以直接正常使用。
protected是类独有的。它是什么意思呢,它的字面意思是受保护的。protected的意思是在类外有一定的可访问性,但是并不是完全public的。 protected成员可以在类的子类中访问,但是无法在类外被访问。准确的说,是可以在继承链**问,因为父类派生出子类,子类可以派生孙子类,子子孙孙无穷尽也。就好比你家的传家宝你祖上传给了你,但是不是外人可以拿的。
和其它访问修饰符一样,protected可以用在方法、字段、属性上。还记得属性访问器吗,set也可以被protected修饰,意思是只能自己和自己的派生类访问。
比如我们修改Animal类中的一些成员访问器的声明:
public string Name { protected set; get;}
那么我们就可以写一个方法在Dog中修改Name属性。
public void ChangeName(string newName) { Name = newName; }
在C++中,成员访问修饰符还可以用来修饰继承的基类,用于更进一步地控制子类的派生类和类外对从父类继承到的成员的访问控制。但是C#不支持这样的操作,所有的继承都相当于是public继承。
继承链中的构造函数
在继承链中,各个类的构造函数仍然会各司其职构造对应的实例。并不是说一个类从其父类派生出来之后,就相当于父类凭空消失了。
当我们派生出一个子类,这个子类仍然拥有父类的所有成员(尽管有些看不到),如果我们要使用一个来自父类的成员,那么就必须要有一个父类实例存在。因此构造一个子类实例时,其从父类继承的部分也需要被正确地构造,如果父类部分没有被正确构造,我们继承的东西也就无从谈起。哪怕我们是进化了无数代的物种,我们也需要正常地生长出无数年前的祖先拥有的四肢等等。
我们用一些简单的代码展示这个事实。 如果Animal提供了一个无参构造函数。在其中我们简单地输出一句话,表示这个构造函数被调用了。
public Animal() { System.Console.WriteLine(“Animal constructed”); }
现在Dog类仍然使用默认构造函数。我们直接构造一个Dog实例。
var dog = new Dog();
我们现在在Dog中也定义一个类似的构造函数。
public Dog() { System.Console.WriteLine(“Dog constructed”); }
最终会在控制台中输出:
Animal constructed Dog constructed
根据输出的顺序我们可以得出的结论是,构造一个子类实例的时候会首先调用父类的构造函数构造父类实例,从而保证子类对其成员对正常使用。
现在我们把Animal唯一的构造函数的参数列表改为接受一个string类型参数。 现在你的编辑器或者IDE可能已经开始提示错误,提示你的子类Dog没有对应的构造函数。
public Animal(string name) { Name = name; }
我们再来思考一下。首先构造Dog必须能够构造Animal,而现在Animal有且只有一个需要string参数的构造函数。因此,如果不为其提供这样的参数那么我们便无法正确构造父类实例,这就是错误的原因。
那么我们怎么给父类的构造函数提供参数呢?
这个时候需要在子类构造函数中的参数列表后,像继承的语法一样,使用冒号,然后跟上base和参数列表。base实际上就是代表基类,这个base(parameter list)实际上就是父类的构造函数。我们通过这样的方式来完成父类部分的正常构造。
public Dog() : base(“A nice name”){}
这个时候我们保持Dog的构造函数为无参数的函数,直接传递给父类一个默认值。但是我们的子类可能也需要在构造函数中直接提供这个参数。这个时候我们可以这样写,然后把参数直接向上传递给父类。由于我们把构造工作“代理”给了父类,如果不需要额外工作,我们可以直接把构造函数体留空。
public Dog(string name) : base(name){}
在后面我们会介绍重载,这个时候我们可以保留同一个方法不同的参数版本。
防止继承
对于有些类,我们可能不希望用户进一步派生更多的子类,这个时候使用sealed关键字表示不允许从这个类派生子类。
public sealed class Human : Animal {}
实际上,所有的结构都相当于是sealed的。
这次的文章略长,我们简单介绍了类,和类的继承。后续文章中我们将介绍面向对象编程中可以说最重要的特性:多态。下一篇文章在介绍多态之前,我们先介绍重载。