类和接口
这篇笔记我们介绍C#语言中和面向对象相关的语法和概念,包括类、对象、接口,以及面向对象中继承、封装、多态在C#中如何实现。本章节可以结合Java/C++对比学习,观察C#的实现和这两种主流语言的异同。
class 类定义
C#中有如下几种类成员概念需要我们掌握:
- Field(字段):类的成员变量,用于存放各种数据类型
- Property(属性):通过属性Get/Set访问器读写字段,此时对外部来说操作的成员就是一个属性
- Method(方法):成员函数,包含一段可执行的逻辑
字段和方法
C#类中,字段和方法的使用和Java一致,下面例子中我们定义了一个Student类,并声明了其字段、构造方法和一个自定义的方法。
class Student
{
private string name;
private int age;
public Student(string name, int age)
{
this.name = name;
this.age = age;
}
public void SayHello()
{
Console.WriteLine("Hello, I am {0}, {1} years old.", name, age);
}
}
代码中,我们声明两个属性为private,因此它们只能在类内部使用;而构造函数和方法SayHello被声明为了public,因此我们可以在任何位置访问。
下面代码我们使用new关键字对类进行了实例化,并调用了对象上的SayHello()方法。访问对象的字段或是方法,都是用.点操作符。
Student s = new Student("Tom", 18);
s.SayHello();
类和结构体的区别是,类实例化为对象后,会在堆内存中分配空间,对象是引用类型而非值类型。
static 静态字段和静态方法
和Java类似,C#也允许类拥有静态字段和静态方法,静态字段位于程序内存的静态区,可以被所有的类实例共享。静态字段一般使用类名和点操作符来访问,而非实例变量。静态字段和静态方法的用途之一的实现单例模式,下面是一段例子代码。
class MyObject
{
private static MyObject myObject = null;
private MyObject() { }
public static MyObject GetInstance()
{
if (myObject == null)
{
MyObject.myObject = new MyObject();
}
return MyObject.myObject;
}
}
调用一个静态方法写法如下。
MyObject m = MyObject.GetInstance();
代码中,使用static关键字声明的就是静态字段和静态方法。当然,上面的单例模式实现并不完整,比如没有考虑线程安全问题等,这里只是简单演示静态字段和方法的使用,有关设计模式这里就不多介绍了。
属性和属性访问器
Java的实体类中为了访问字段经常出现一大堆Get/Set方法,十分不优雅,IDE都会自动帮我们把Get/Set方法折叠起来,甚至发展出Lombok这种邪路。而C#提出了属性的概念,同时支持属性访问器写法,相比Java能够大幅简化代码并提高可读性,属性访问器写法例子如下。
class Student
{
public string Name
{
get { return this.Name; }
set { this.Name = value; }
}
public int Age { set; get; }
}
上面代码中,Name属性下面编写了一组get和set代码块,这就是属性访问器,代码块中我们可以像编写方法一样加入代码逻辑,这里我们就是简单的返回了属性的值,另外注意set块中,C#规定使用value关键字代表传入的参数。Age属性则使用了一种简化的属性访问器写法,它代表不做任何处理直接进行属性的返回或赋值。
这里我们的Name和Age属性都是公有访问的,因此使用了首字母大写的形式。对于属性访问器,有时我们需要类似“公有读,私有写”的需求,此时也可以单独指定get和set访问器的权限。
public class Student
{
public string Name { get; private set; }
}
操作属性和操作字段的写法是一样的,都是使用.点操作符,例子代码如下。
Student s = new Student();
s.Age = 18;
int age = s.Age;
除此之外,C#也支持静态属性,这里就不多介绍了。
常量字段(const)和只读字段(readonly)
C#支持在类内部声明常量字段成员,对于类中一些固定不会再改变的数据字段,声明为常量字段是一个比较好的做法。常量成员需要在声明的同时初始化,其值需要在编译期确定,且不可再更改,这是因为常量会在编译期对引用常量的位置代码进行替换,常量在运行期并不存在于数据段的内存中。
class MathConstants
{
public const double Pi = 3.14;
}
常量字段的取用类似静态成员,都是使用类名和点操作符,不过要注意的是声明为常量的成员就不能再声明为静态成员的。
double d = MathConstants.Pi;
readonly字段和常量字段本质上的区别就是readonly字段存在于运行期的数据段内存,它本质和对象的字段没有任何区别,只不过是出于程序规范性的设计,语法限制其为只读。
class Dog
{
private readonly string voice;
public Dog(string voice)
{
this.voice = voice;
}
public void Bark()
{
Console.WriteLine(voice);
}
}
readonly修饰的成员可以在声明语句或是构造函数中进行初始化。
partial 部分类
C#中一个比较实用的语法是partial部分类。考虑这样一种场景,我们编写一个Java Swing程序,IDE具备一个图形界面设计器(如Window Builder),它会生成一部分类的代码;而我们自己也需要编写一部分类的代码。如果没有部分类的设计,我们就不得不和IDE修改同一个代码文件,这可能造成恼人的冲突问题。而C#提供了部分类语法,可以将一个类分开到不同代码块,这样我们修改类的内容不会影响到IDE的解析,IDE维护的代码也不会污染我们的代码,Winform就是基于部分类设计的。
partial class Dog
{
private string voice;
public Dog(string voice)
{
this.voice = voice;
}
}
partial class Dog
{
public void Bark()
{
Console.WriteLine(voice);
}
}
上面代码中,我们定义了两个Dog类,但它们都是partial class,也就是部分类。在运行时,它们的表现和一个类没有任何区别。
static 静态类
C#支持静态类的概念,静态类中只能包含静态成员。
static class A
{
private static string data = "hello";
public static void Say()
{
Console.WriteLine(A.data);
}
}
静态类不能使用new关键字进行实例化,对于某种只包含静态方法和字段的工具类等,将其声明为静态类是一个好的习惯,它能够防止工具类的使用者错误的进行实例化操作。此外,静态类是密封的,不能被继承。
成员访问修饰符
上面代码中我们使用过了public和private访问修饰符,这两种访问修饰符也是最为常用的。C#语言中,一共提供了如下几种成员的访问修饰符:
| 访问修饰符 | 说明 |
|---|---|
| public | 公有访问,不受任何限制 |
| private | 私有访问,仅限于本类内部,子类等均不可访问 |
| protected | 保护访问,仅限于本类和子类访问 |
| internal | 内部访问,仅限于本程序集访问 |
| protected internal | 内部保护访问,在internal基础上还允许跨程序集的子类访问父类 |
相比Java,C#增加了internal和protected internal两种很实用的访问修饰符,对跨程序集的可访问性做出了限制。
关于默认访问修饰符则有点复杂,其规则如下:
- 命名空间下只允许使用
public和internal,默认为internal。 - 类中的所有成员默认为
private,接口成员默认为public,委托默认为internal。 - 派生类的可访问性不能高于基类;成员的可访问性不能高于包含该成员的类。
方法
C#作为一种面向对象语言,函数可以作为类的成员,在OOP中这类函数叫做「方法」(Method)。方法包含两部分:方法声明和代码块。
class Account
{
private int Money { get; set; }
/// <summary>
/// 转账方法
/// </summary>
/// <param name="money">转入金额</param>
/// <returns>当前账户金额</returns>
public int transferMoney(int money)
{
this.Money += money;
return this.Money;
}
}
上面代码是一个典型的方法定义,声明部分包含了方法的返回值、方法名称和参数,大括号内的方法具体的执行逻辑。作为一个类的成员,我们这里使用public对方法进行修饰,因此在代码的任何位置都可以通过Account的实例对该方法进行访问。我们这里还使用了标准的///注释对方法的作用、参数、返回值进行了说明,编写具有良好注释的代码是推荐的做法。
命名参数
C#支持类似Python的命名参数语法,调用方法时可以不严格指定参数的顺序。
Student s = new Student(name: "Tom", age: 18);
上面例子中,我们Student类的构造函数声明为public Student(string name, int age),我们调用时使用了命名参数的方式,此处我们写作new Student(age: 18, name: "Tom")也是可以的。
默认参数
C#支持方法的默认参数语法,调用方法时,带有默认值的参数可以使用默认值而不必再传。
class Student
{
private string name;
private int age;
public Student(string name, int age = 18)
{
this.name = name;
this.age = age;
}
}
局部函数
局部函数其实就是在方法内部再定义一个函数,局部函数和局部变量一样,只能在方法作用域内使用。适当的使用局部函数抽离可复用的逻辑,对代码结构的优化十分有帮助。
class Program
{
static void Main()
{
void init()
{
Console.WriteLine("初始化中...");
}
init();
Console.WriteLine("成功!");
}
}
上面代码中,我们在Main方法内部定义了局部函数init并调用。
扩展方法
C#中的扩展方法是一个特别好用的特性,它能够为一个已经存在的类添加方法。这在底层框架的开发中特别有用,例如我们的一个底层工具类被拆分成了多个模块,安装子模块可以为工具类添加不同的扩展功能,这就可以用扩展方法来实现。微软在.Net Core中提供的许多包就大量使用了扩展方法。
namespace Gacfox.Demo.DemoNetCore
{
public class Student
{
public string name { get; set; }
public int age { get; set; }
}
public static class StudentExtension
{
public static void SayHello(this Student student)
{
Console.WriteLine($"Hi, my name is {student.name}. I am {student.age} years old.");
}
}
}
上面代码中,我们首先定义了类Student,而随后定义了一个静态类StudentExtension,其中包含了静态方法SayHello(),它就是一个扩展方法。只要Student类的使用者引入了StudentExtension的命名空间,就可以在Student实例上调用SayHello()方法。扩展方法中,其第一个参数需要使用this关键字指定,同时其类型需要指定为被扩展的类,代表执行扩展方法的对象实例。
Student student = new Student();
student.name = "Tom";
student.age = 18;
student.SayHello();
调用扩展方法时没什么特别的,只要引入了对应的命名空间,我们直接像正常使用类的写法即可。
继承
继承是OOP面向对象编程中最重要的概念,继承表达的是软件工程中的泛化关系。C#中,我们使用继承能够定义类的派生类。
class Animal { }
class Dog : Animal { }
C#中,继承使用:冒号表达。上面例子代码中,Dog类继承Animal类。关于子类能够访问父类的哪些方法,可以参考上面成员访问修饰符章节。
方法重写
方法重写用于实现面向对象的多态。C#语言中,子类可以重写(override)父类的虚方法(virtual)和抽象方法(abstract)。虚方法和抽象方法的区别:
- 虚方法:使用
virtual修饰,用于修饰父类方法,如果子类未重写该方法,则调用父类的虚方法实现 - 抽象方法:使用
abstract修饰,仅用于抽象类,抽象方法只有声明没有实现
重写基类的方法需要使用override关键字修饰。
虚方法
下面例子代码中,类A中包含了虚方法Foo,类B重写了这个虚方法。
class A
{
public virtual void Foo()
{
Console.WriteLine("Function a.Foo is called.");
}
}
class B : A
{
public override void Foo()
{
Console.WriteLine("Function b.Foo is called.");
}
}
分别实例化A和B,其中B用子类实现了父类类型,调用Foo方法时我们会获得不同的结果。
A obj1 = new A();
obj1.Foo(); // 输出:Function a.Foo is called.
A obj2 = new B();
obj2.Foo(); // 输出:Function b.Foo is called.
抽象类和抽象方法
抽象方法必须定义在抽象类中,抽象类可以包含普通方法,也可以包含抽象方法,抽象方法只有声明没有实现;抽象类不能实例化,只能实例化其派生类,且派生类必须重写父类的抽象方法。
abstract class A
{
public abstract void Foo();
}
class B : A
{
public override void Foo()
{
Console.WriteLine("Function Foo is called.");
}
}
A o = new B();
o.Foo(); // 输出:Function Foo is called.
密封类和密封方法
密封类使用sealed修饰,密封类不能被继承。
sealed class A {}
sealed修饰方法时则需要和override结合使用,表示该方法是从基类重写的,但不允许派生类进一步重写。
class B : A
{
public override sealed void Foo()
{
// ...
}
}
此时继承B的子类不能继续重写Foo方法。
成员覆盖
C#支持成员覆盖语法。覆盖和重写不同,两者的区别是:用子类实例化父类类型,得到一个父类对象,在其上调用父类定义的方法,重写会改变该方法执行子类中的方法定义,覆盖不会。
此外,覆盖不仅可以用于方法,也可以用于字段和属性,子类中覆盖父类的成员需要使用new关键字修饰。
下面例子实现了子类覆盖基类方法。
class A
{
public void Foo()
{
Console.WriteLine("Function A.Foo is called.");
}
}
class B : A
{
public new void Foo()
{
Console.WriteLine("Function B.Foo is called.");
}
}
父类和子类对象分别用不同方式进行实例化,输出如下。
A obj1 = new A();
obj1.Foo(); // 输出:Function a.Foo is called.
A obj2 = new B();
obj2.Foo(); // 输出:Function a.Foo is called.
B obj3 = new B();
obj3.Foo(); // 输出:Function b.Foo is called.
使用覆盖时还有个问题需要注意,如果子类覆盖了父类成员,this中的能够访问到的成员是覆盖后的,此时可以使用base关键字访问父类成员。
class A
{
protected string data = "aaa";
}
class B : A
{
protected new string data = "bbb";
public void Foo()
{
Console.WriteLine("base {0} this {1}", base.data, this.data);
}
}
调用Foo方法输出base aaa this bbb。
继承情况下的构造函数
C#语言中,继承情况下,子类会默认先调用父类的无参构造函数,也可以指明调用父类的带参数构造函数。
class A
{
public A()
{
Console.WriteLine("A构造函数执行");
}
}
class B : A
{
public B() : base()
{
Console.WriteLine("B构造函数执行");
}
}
上面代码中public B() : base()声明表示调用B()前先执行父类无参构造函数,也就是A()。其实这里: base()可以省略不写,因为这是默认行为,这里显式写出只是为了演示相关的用法。
对于父类的带参数构造函数,我们就不能省略base()了,必须显示写出,其参数可以直接使用构造函数的参数,也可以使用字面值。下面是一个例子:
public B(int age, string name) : base(age, name) { }
调用base()时和普通函数一样,我们也可以使用命名参数:
public B(int age, string name) : base(age: age, name: name) { }
接口
C#中接口使用和Java差不多。C#中,接口建议使用大写字母I开头,实现接口和继承一样使用冒号,接口中声明的方法不需要任何访问修饰符。
interface IAnimal
{
void Bark();
}
class Dog : IAnimal
{
private string voice = "woof";
public void Bark()
{
Console.WriteLine(this.voice);
}
}
下面我们使用实现类实例化了接口类型。
IAnimal animal = new Dog();
animal.Bark();
类实现多个接口
C#不允许多继承,但允许实现多个接口。
class Dog : IAnimal, IWalk { }
代码中,多个接口使用逗号分隔。
接口的继承
和Java一样,C#的接口可以具有继承关系。
interface A { }
interface B : A { }
上面代码中,接口B继承接口A。