C#杂记之陆:真·杂记

注:以下文稿写于2020年,虽未完整,为了不卡杂记的序号,还是发出来了。
日后会在这里添加更多内容,也可能另起一篇拾遗(

这一篇是真正的“杂”记。这段时间在刷LeetCode的同时,阅览了一些Java中常见的问题,鉴于Java与C#的殊途同归,也有了些收获。

为查漏补缺,将之前理解还不够明了的部分记在这里,主要是涉及OOP思想的一些概念。

关于多态

面向对象设计有四大特征:

  • 抽象:通过类和接口的属性与方法实现
  • 继承:通过类和接口的继承实现
  • 封装:通过访问控制符实现
  • 多态:???

多态这个概念比较神秘。以前看到一个解释是这么说的:

继承是子类引用父类方法;多态是父类引用子类方法。

当时看了觉得有点奇怪,父类怎么能引用子类方法呢?现在我的理解是如下:

子类对象继承了父类方法,但是可能又对继承过来的父类方法进行了重写覆盖。而一切出现父类的地方都可以用其子类向上转型进行替换(里氏替换原则),调用方法时调用的会是在子类里被重写的子类方法。继承、重写和向上转型,这是实现多态的三要素。

这样,如果一个父类有多个子类,它们又分别用不同方式重写了继承下来的同一个方法,那么在把这些子类都当父类使用时,就会展现出多态性:明明是对同一个类的对象调用了同一个方法,但行为却完全不同。

举个例子,假如有个扬声器类,包含一个大叫()方法,调用时会“嘤嘤嘤”;而继承自它的有大叫“洒↓比↑”的喇叭类和大叫“哼,哼,啊啊啊啊啊啊啊”的野兽先辈类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//这段代码甚至真的可以跑,C#支持用汉字做标识符,大概是因为用了Unicode
using System;
using System.Collections.Generic;

namespace PlayGround
{
class 扬声器
{
public virtual void 大叫() => Console.WriteLine("嘤嘤嘤");
}

class 喇叭 : 扬声器
{
public override void 大叫() => Console.WriteLine("洒↓比↑");
}

class 野兽先辈 : 扬声器
{
public override void 大叫() => Console.WriteLine("哼,哼,啊啊啊啊啊啊啊");
}

class 屑狐狸 : 扬声器
{
public override void 大叫() => Console.WriteLine("呜呜呜呜,好可怜啊");
}

class Program
{
static void Main(string[] args)
{
var 一堆扬声器 = new List<扬声器>() { new 扬声器(), new 喇叭(), new 野兽先辈(), new 屑狐狸() };

foreach (var 某个扬声器 in 一堆扬声器)
{
某个扬声器.大叫();
// 输出:
// 嘤嘤嘤
// 洒↓比↑
// 哼,哼,啊啊啊啊啊啊啊
// 呜呜呜呜,好可怜啊
}
}
}
}

不过,另外有一种操作是“成员隐藏”,在派生类中用new修饰符对基类已有的成员重新进行定义,在这种情况下体现不出多态,因为向上转型时调用的仍然会是基类已经定义的成员。

在竞技世界面试中,还被问到这样一个问题:多态的实现方法是什么?

我确实没搞明白什么叫“实现方式”,于是按照上面的部分答了,但那其实是多态存在的必要条件。

其实现在理解来,实现方式就是指什么情况下会体现出多态。

方式一是重写,就像上面代码中的例子;方式二是接口,不同类对接口中方法的实现不同造成多态;方式三是抽象类和抽象方法,子类对抽象方法进行重写时的实现不同造成多态。

(其实我对着这几句话想了半天,觉得它们说的几乎是同一件事……)

关于接口和抽象类的区别

这两个都是无法被实例化的东西。

在较早版本的Java中,接口仅包含公有属性和方法,而且方法全部都是隐式抽象方法,没有任何实现,也不可以定义静态方法,仅仅是对实现接口的类的一种规约。

但在Java 8之后可以定义静态方法,而且方法还可以有方法体了。

C#情况也差不多,接口里可以包含方法(包括static方法)、属性、索引器、事件的声明。原本的接口完全是抽象的,不包含任何实现代码。

但和Java一样,从C# 8.0开始,接口可以为成员定义默认实现了。

这么看来,如今接口和抽象类的区别正变得不是那么大,但它们的设计目的始终是不变的:接口用来进行“有”约束,抽象类用来进行代码复用。

不过文档里有句话让我比较在意:

接口不能声明实例数据,如字段、自动实现的属性或类似属性的事件。

字段和属性有什么区别?

属性Property和字段Field

以前在学习C++和Java的时候,我往往把“属性”和“数据成员”当作同一种东西,而且它们在课本上表现得确实就是同一种东西。但在实际的语言中,数据成员被称为字段,和属性有一些不同。

字段就像SQL里所表述的,它是真正承载数据的部分。一般情况下,我们趋向于不直接暴露字段,而是将它设为private。在命名规范上,字段一般使用驼峰命名法,即第一个单词首字母小写,其它首字母大写。由于字段一般是私有的,命名上往往还会加上下划线前缀,如private string _myField

属性其实是上一篇中所提到的get/set访问器的外壳。它自己不承载数据,而是将字段暴露给外界进行读/写,更像是一种方法。属性对应的字段被称为属性的“后备变量”。属性和方法一样采用帕斯卡命名法,即每个单词的首字母都大写。

1
2
3
4
5
6
7
8
9
10
11
12
13
private string _myField;

public string MyProperty
{
get
{
return _myField;
}
set
{
_myField = value;
}
}

由此延伸出的是自动属性,这是在属性的访问器不需要进行特别的定义时使用的。实际上,上面的例子就可以使用自动属性:

1
public string MyProperty{ get; set; }

自动属性当然不是没有对应的字段,而是由编译器自动创建了一个隐藏的后备变量。

使用属性而不是直接使用字段的一个好处是,你可以给get/set单独设置访问控制符,甚至可以不设置set访问器,使其成为只读属性:

1
2
3
4
5
//使set访问器为private
public string MyProperty{ get; private set; }

//只读属性
public string MyProperty{ get; }

另外,还可以在用属性读写字段的过程中做一些别的操作,比如验证值的合法性,就不举例了。

常量const和只读readonly

constreadonly修饰的字段都不能被修改,但它们也有区别。

const常量是在编译时被编译器确定,用其值取代了所有使用到它的地方,类似于C语言的宏定义。

readonly的值是在运行时被构造函数决定的,它仍然是个变量,只不过它的值只能在构造函数中分配。对于引用类型,readonly会使其自始至终只引用同一个实例,但实例本身可以修改。比如一个readonlyList<T>,不能在构造函数之外对其直接赋值,但是可以调用它的Add()方法,向其中添加元素。

1
2
3
4
5
6
7
8
9
10
11
12
class Program
{
static private readonly List<int> list = new List<int> { 1, 2, 3, 4 };

static void Main(string[] args)
{
list.Add(5);

// 报错,无法对静态只读字段赋值(静态构造函数或变量初始值中除外)
list = new List<int> { 5, 6 };
}
}

这类似于C语言的指针常量:指针本身,即 “指针指向的是什么地址” 不能修改,但 “指向的地址上写了什么东西” 是可以修改的。

宏、条件编译

注意,C#没有宏定义这一机制!

确切地说,C#的预处理器不会像C语言一样,将源代码的内容根据宏定义进行替换。像#define ...这样的预处理器指令,并不是用来进行宏定义的,它仅仅用于设置条件编译符号。而且,这些符号也不能像在C语言中一样被分配值。

条件编译符号还可以通过系统环境变量、编译器命令行选项提供。

条件编译符号的使用场景当然是条件编译。配合另外几条预处理器指令,可以选择一定情况下编译哪些代码:

  1. #if:开始条件编译代码块,仅在定义了指定的符号时,才会编译其中的代码。
  2. #elif:结束前面的条件编译代码块,并基于是否定义了指定的符号,开始一个新的条件编译代码块。
  3. #else:结束前面的条件编译代码块,如果没有定义前面指定的符号,开始一个新的条件编译代码块。
  4. #endif:结束前面的条件编译代码块。
  5. #undef:取消符号的定义。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//注意符号的定义和取消要放在文件最前面
// #define DEBUG
##define VC7

//...
##if DEBUG
Console.WriteLine("Debug build");
##elif VC7
Console.WriteLine("Visual Studio 7");
##else
Console.WriteLine("No symbol defined");
##endif

//输出"Visual Studio 7"

另外,还有通过特性attribute实现条件编译的方法。具体在下一篇的特性部分记录。