本文面向.NET C#初学者,介绍C#中的方法修饰符的含义和使用以及注意事项。
理解C#基本语法(如方法声明)
理解OOP基本概念(如多态)
在C#中,一个方法通常按如下形式声明
[访问修饰符] [方法修饰符] [返回类型] 方法名(参数列表)
例如,一个方法的声明如下:
public virtual async Task HelloAsync();
其中的virtual
与async
就是方法修饰符,方法修饰符为编译器指示方法的的特性,从而让编译器对方法进行特别处理。例如,这里的方法修饰符指示该方法是一个可被子类重写的虚方法(virtual)
,并且是一个异步方法(async
)。
本文重点介绍[方法修饰符],在C#中,有如下方法修饰符:
abstract
virtual
override
sealed
new
async
static
readonly
extern
partial
unsafe
大多数方法修饰符之间存在互斥性,即一个修饰符使用后则无法使用另一个修饰符。不需要刻意记忆互斥关系,只需要理解各个修饰符的含义即可。
按照不同的归类方式,可以把上述方法修饰符归为几组,这里我们按照修饰符的性质进行分类:
实现多态 | 用于封装 | 改变性质 | 特性指示 |
virtual | sealed | static | async |
override | new | readonly | |
abstract | extern | ||
partial | |||
unsafe |
下面按以上组织逐一介绍各个修饰符
(1)使用
virtual修饰符主要用于标记一个方法可被子类重写,其主要使用方法如下所示:
class Base { public virtual void Hello() { Console.WriteLine("Hello, I am Base"); } }
要重写被virtual修饰的方法,需要在子类中声明相同函数签名的方法,并使用override修饰符:
class Dervied : Base { public override void Hello() { Console.WriteLine("Hello, I am Dervied"); } }
下面是调用示例:
Base b = new Base(); Dervied d = new Dervied(); b.Hello(); // Hello, I am Base d.Hello(); // Hello, I am Dervied b = d; b.Hello(); // Hello, I am Dervied
上述代码中第三次的输出将会输出"Hello, I am Dervied"。这是由于虽然变量b是一个Base类型的引用,但是其实际指向的是一个Dervied类型的对象,由于Dervied重写了其基类Base的Hello方法,故通过变量b调用Hello方法时,根据多态性,此时实际调用的是Dervied中定义的Hello方法。
(2)特别说明
virtual与override用于修饰方法,但由于在C#中属性的本质也是方法,因此也可以将其应用到修饰属性上,实现属性的多态性,如下:
class Base { public virtual int Value { get { return 0; } } } class Dervied : Base { public override int Value { get { return 1; } } } Base b = new Dervied(); Console.WriteLine(b.Value); // 输出是1,因为此时实际调用的是Dervied中定义的Value属性
(1)使用
abstract修饰符同样用于标记一个方法可被子类重写,但是其使用有严格的限制:
所谓抽象方法就是只有方法声明而没有方法体,且被abstract修饰的方法,这种方法只能定义在抽象类(abstract class)中,如下:
abstract class Base { public abstract void Hello(); }
Hello是一个抽象方法,只定义了方法签名而没有定义方法体,因此它的实际表现由继承类决定,因此继承类必须重写父类中的抽象方法(除非继承类依然为抽象类则可以不用重写),重写abstract方法和重写virtual方法一致:
class Dervied : Base { public override void Hello() { // do some thing } }
(2)特别说明
显然abstract修饰符的使用相当固定,虽然看起来C#完全可以将没有方法体的方法默认为抽象方法从而避免使用abstract修饰符,之所以保留此设计可能是为了增强语义。
基本上所有使用abstract修饰方法的地方都可以使用virtual替代,abstract最主要的特性其实在于其会强制要求子类重写被其修饰的方法,是一种编码规范的协定。从某种意义上来说,abstract其实更倾向于用来模拟接口方法声明。如下:
abstract class IFlayable { public abstract void Fly(); }
上述声明其实就类似于下面的接口声明:
interface IFlyable { void Fly(); }
(1)使用
用于修饰方法时,sealed的含义是:被修饰的方法无法被子类重写。由于只有父类中被声明为virtual或者abstract的方法才可被其子类重写,而显然你不能将sealed修饰符配合virtual或abstract使用,因此,只有在其子类中被重写的方法才有继续被子类的子类重写的可能。如下述代码:
class A { public virtual void Hello() { Console.WriteLine("I am A"); } } class B : A { public override void Hello() { Console.WriteLine("I am B"); } } class C : B { // 此Hello方法将再次重写基类的Hello方法 public override void Hello() { Console.WriteLine("I am C"); } }
基类A中定义了virtual方法Hello,尽管类型B已经重写了基类A中的Hello方法,而类型C继承自类型B,但是你依然可以在类型C中再次重写与基类A中定义的Hello方法。有时候基于一些封装的需求,你可能希望避免上述情况发生,就需要用到sealed修饰符。如下:
class A { public virtual void Hello() { Console.WriteLine("I am A"); } } class B : A { public sealed override void Hello() { Console.WriteLine("I am B"); } } class C : B { // 此方法无法通过编译,因为类型B中将Hello方法设为了sealed方法 public override void Hello() { Console.WriteLine("I am C"); } }
简而言之,sealed修饰符就是让“可被重写”的方法在子类中回归到“不可重写”的状态
(1)使用
有时候可能会在子类中定义与基类方法签名相同的方法,但基类没有将该方法用virtual或abstract标记为“可重写”,如以下:
class Base { public void Hello() { Console.WriteLine("Hello, I am Base"); } } class Dervied : Base { public void Hello() { Console.WriteLine("Hello, I am Dervied"); } }
上述代码可以通过编译,Dervied中定义的Hello方法将会隐藏Base中定义的Hello方法,但是会收到编译器的警告。这个时候就可以使用new关键字来强制让子类覆盖基类中签名相同的方法,并避免编译器警告。
class Base { public void Hello() { Console.WriteLine("Hello, I am Base"); } } class Dervied : Base { // 通过new修饰后,将不会有编译器警告 public new void Hello() { Console.WriteLine("Hello, I am Dervied"); } }
然而,这一显式覆盖行为同样不会提供多态性,这意味着会有下面的代码执行结果:
Base b = new Base(); Dervied d = new Dervied(); b.Hello(); // Hello, I am Base d.Hello(); // Hello, I am Dervied b = d; b.Hello(); // Hello, I am Base
第三次调用Hello时,虽然此时b已经指向了一个Dervied对象,然而在调用Hello方法时,调用的依然是Base中定义的Hello方法。换言之,Hello方法不具有多态性。实际上,new修饰符的含义是:被修饰的方法与基类的相似签名的成员无任何关系。
(2)特别说明
除非有不得已而为之的理由,否则当子类方法签名与父类冲突时,应当优先考虑修改方法名避免冲突,而不是使用new修饰符。
除了用于方法外,new亦可以用于属性、字段甚至事件:
class Base { public event Action? Action; public int Field; public int Property { get; set; } } class Dervied : Base { public new event Action? Action; public new int Field; public new int Property { get; set; } }
请记住,new修饰符的实际含义是:被修饰的方法与基类中相似签名的成员无任何关系。
(1) 使用
默认情况下,在类中声明的方法是实例方法,其调用需要通过类的实例进行调用,如下:
class Printer { public void Hello() { Console.WriteLine("Hello"); } } Printer p = new Printer(); p.Hello(); // 通过Base类的实例来调用Hello方法
大多数情况下,这一行为是合理的,因为类的方法往往涉及到对其实例字段的访问和修改。然而有时候一个方法可能不需要访问任何实例字段,例如,定义一个有Add方法进行加法运算的Math类:
class Math { public int Add(int a, int b) { return a + b; } }
要使用这个Math类的Add方法,需要按下述步骤调用:
Math math = new Math(); int n = math.Add(1, 2);
然而这一过程稍显繁琐,Add方法本身不依赖Math类中的任何实例字段属性,完全可以独立运行,并且创建对象需要消耗额外的空间和时间。为了避免这一无意义的行为,可以考虑绕过实例化来直接调用Add方法。此时便可以使用static修饰符。static修饰符指示一个方法不会访问类中的实例字段,并且不需要实例化可直接使用类自身来调用,如下:
class Math { public static int Add(int a, int b) { return a + b; } }
要使用这个Math类的Add方法,只需要像下面这样调用:
int n = Math.Add(1, 2);
(2)特别说明
可以将static方法视为由类管理的函数。
同样的,static修饰符也可以用于修饰字段、属性与事件:
class Foo { public static Action? StaticEvent; public static int StaticField; public static int StaticProperty { get; set; } } // 直接通过类名调用 Foo.StaticEvent; Foo.StaticField; Foo.StaticProperty;
对于静态类(static class),所有成员都需要使用static修饰。
(1) 使用
async修饰符用于指示一个方法为异步方法,需要配合方法体内的await关键字使用。关于异步方法的概念这里碍于篇幅不进行阐述,仅在此说明其使用。示例如下:
class Printer { public async void HelloAsync() { await Task.Delay(1000); Console.WriteLine("Hello"); } }
需要注意的是async的作用仅仅是标记方法为异步方法,并非指示该方法要异步调用,也就是说,在下面的实例中,尽管Wait1被aysnc标记,但Wait1和Wait2的实际表现是一样,都是同步方法,都会将调用线程阻塞1秒:
class Foo { public async void Wait() { Thread.Sleep(1000); } public void Wait() { Thread.Sleep(1000); } } Foo foo = new Foo(); foo.Hello1(); // 阻塞1秒 foo.Hello2(); // 阻塞1秒
要真正发挥async的作用,需要配合TAP(Task-based Asynchronous Pattern)异步设计模式
(2)特别说明
作为编码规范,被async修饰的方法名应当以Async结尾,如:
async void HelloAsync();
(1)使用
extern指示方法由外部实现,通常配合P/Invoke使用来调用由其他语言写成的API。例如,下述方法中声明表示该方法实际需要调用C语言编写的math库中的Add方法:
[DllImport("math.dll")] private static extern int Add(int a, int b);
注意上述方法除了被extern修饰外,还需要被static修饰。这是可以理解的,因为从外部库中调用的方法显然不会是用于本类的实例方法(从设计逻辑与实现逻辑上都说不通)。
(2)注意事项
被调用的由其他语言写成的API需要遵循一定的编码规范,因此并非所有函数都可以像上述那样被简单调用。考虑到篇幅和文章重点,这里不做赘述。
(1)使用
在开始介绍partial方法前,需要先介绍分部类(partial class),因为这partial方法需要配合分部类使用。简单来说,partial class就是指一个类可以在多个地方定义类成员,编译时由编译器进行合并。例如有如下分部类声明:
partial class Printer { public void Hello() { Console.WriteLine("Hello"); } } partial class Printer { public void World() { Console.WriteLine("World"); } }
在编译时,编译器将各个部分相同的类型实现进行合并,因此实际效果等同于以下声明:
class Printer { public void Hello() { Console.WriteLine("Hello"); } public void World() { Console.WriteLine("World"); } }
尽管看起来分部类似乎让事情变得麻烦,但实际上分部类有许多实际作用,例如用于合并用户代码与生成代码,一个应用场景即合并WPF或WinForm窗口设计器自动生成的代码与用户编写的代码。此外,分部类也可方便于类的协作开发。有关分布类的详细信息,请参考官方文档:分部类和方法
上面是分部类基本使用知识。下面来介绍和分部类配合使用的由partial修饰的方法,这称之为分部方法。例如对于以下声明:
class Printer { public void Hello() { Console.WriteLine("Hello"); } }
可以使用分部类和分部方法修改为以下声明形式:
partial class Printer { public partial void Hello(); } partial class Printer { public partial void Hello() { Console.WriteLine("Hello"); } }
两者在效果和实质上都是相同的。你可能会好奇这一行为有何意义,毕竟这似乎没有带来什么便捷,还会多书写一次方法声明。实际上,有时候方法的实现可能是有代码生成器生成,这时候就需要分部方法来帮助合并由用户定义的方法声明与由代码生成器完成的方法实现。
(2)注意事项
如果分部方法满足以下条件,则可以不用提供代码实现。
virtual
、override
、sealed
、new
或 extern
实际上,在编译时编译器会删除满足上述条件且没有实现的分部方法的调用。
(1)使用
不同于其他修饰符,readonly只能用于修饰结构体的方法声明,其含义为:方法体不会修改结构体的实例字段。示例声明如下:
struct Point { public float X; public float Y; public readonly void Print() { Console.WriteLine(X + "," + Y); } }
上述的readonly修饰符指示Print方法不会修改实例字段X和Y,方法中只存在访问行为。如果尝试在readonly方法中修改实例字段,将导致编译错误。
(2)特别说明
实际上配合ref,readonly也可以用于修饰类方法,然而此时的readonly有完全不同的语义,例如:
class Grid { private Point _origin = new Point(); public ref readonly Point GetOrigin() { return ref _origin; } }
上述声明中的readonly实际的含义是:返回的ref引用为不可修改的只读引用。也就是说下面的代码无法通过编译:
Grid grid = new Grid(); ref Point p = ref grid.GetPoint(); // 错误 p.X = 1;
可通过为ref变量添加readonly声明来保证不会修改只读引用的返回值:
ref readonly Point p = ref grid.GetPoint();
(1)使用
unsafe实际就是指示方法可以运行不安全代码,它是unsafe关键字的方法级声明。一个简单的unsafe方法如下:
class Math { public static unsafe void Increase(int* value) { *value += 1; } }
Math类的Increase方法接受一个int指针,并将指向的值+1。该方法涉及到指针操作,并且需要接受一个指针类型的参数,因此需要使用unsafe对方法进行标记。unsafe的方法调用和一般方法调用相似:
int n = 10; unsafe { Math.Increase(&n); } Console.WriteLine(n); // 11
请注意unsafe不必是static方法,这里只是为了方便调用将方法声明为了static。此外,这里需要使用unsafe块并不是因为Increase是unsafe方法,而是因为需要使用取址符&
获取变量n的地址传递给该方法。
(2)特别说明
编译unsafe代码需要指定AllowUnsafeBlocks
编译器选项