C#杂记之陆:真·杂记
注:以下文稿写于2020年,虽未完整,为了不卡杂记的序号,还是发出来了。
日后会在这里添加更多内容,也可能另起一篇拾遗(
这一篇是真正的“杂”记。这段时间在刷LeetCode的同时,阅览了一些Java中常见的问题,鉴于Java与C#的殊途同归,也有了些收获。
为查漏补缺,将之前理解还不够明了的部分记在这里,主要是涉及OOP思想的一些概念。
¶关于多态
面向对象设计有四大特征:
- 抽象:通过类和接口的属性与方法实现
- 继承:通过类和接口的继承实现
- 封装:通过访问控制符实现
- 多态:???
多态这个概念比较神秘。以前看到一个解释是这么说的:
继承是子类引用父类方法;多态是父类引用子类方法。
当时看了觉得有点奇怪,父类怎么能引用子类方法呢?现在我的理解是如下:
子类对象继承了父类方法,但是可能又对继承过来的父类方法进行了重写覆盖。而一切出现父类的地方都可以用其子类向上转型进行替换(里氏替换原则),调用方法时调用的会是在子类里被重写的子类方法。继承、重写和向上转型,这是实现多态的三要素。
这样,如果一个父类有多个子类,它们又分别用不同方式重写了继承下来的同一个方法,那么在把这些子类都当父类使用时,就会展现出多态性:明明是对同一个类的对象调用了同一个方法,但行为却完全不同。
举个例子,假如有个扬声器
类,包含一个大叫()
方法,调用时会“嘤嘤嘤”;而继承自它的有大叫“洒↓比↑”的喇叭
类和大叫“哼,哼,啊啊啊啊啊啊啊”的野兽先辈
类:
1 | //这段代码甚至真的可以跑,C#支持用汉字做标识符,大概是因为用了Unicode |
不过,另外有一种操作是“成员隐藏”,在派生类中用new
修饰符对基类已有的成员重新进行定义,在这种情况下体现不出多态,因为向上转型时调用的仍然会是基类已经定义的成员。
在竞技世界面试中,还被问到这样一个问题:多态的实现方法是什么?
我确实没搞明白什么叫“实现方式”,于是按照上面的部分答了,但那其实是多态存在的必要条件。
其实现在理解来,实现方式就是指什么情况下会体现出多态。
方式一是重写,就像上面代码中的例子;方式二是接口,不同类对接口中方法的实现不同造成多态;方式三是抽象类和抽象方法,子类对抽象方法进行重写时的实现不同造成多态。
(其实我对着这几句话想了半天,觉得它们说的几乎是同一件事……)
¶关于接口和抽象类的区别
这两个都是无法被实例化的东西。
在较早版本的Java中,接口仅包含公有属性和方法,而且方法全部都是隐式抽象方法,没有任何实现,也不可以定义静态方法,仅仅是对实现接口的类的一种规约。
但在Java 8之后可以定义静态方法,而且方法还可以有方法体了。
C#情况也差不多,接口里可以包含方法(包括static方法)、属性、索引器、事件的声明。原本的接口完全是抽象的,不包含任何实现代码。
但和Java一样,从C# 8.0开始,接口可以为成员定义默认实现了。
这么看来,如今接口和抽象类的区别正变得不是那么大,但它们的设计目的始终是不变的:接口用来进行“有”约束,抽象类用来进行代码复用。
不过文档里有句话让我比较在意:
接口不能声明实例数据,如字段、自动实现的属性或类似属性的事件。
字段和属性有什么区别?
¶属性Property和字段Field
以前在学习C++和Java的时候,我往往把“属性”和“数据成员”当作同一种东西,而且它们在课本上表现得确实就是同一种东西。但在实际的语言中,数据成员被称为字段,和属性有一些不同。
字段就像SQL里所表述的,它是真正承载数据的部分。一般情况下,我们趋向于不直接暴露字段,而是将它设为private
。在命名规范上,字段一般使用驼峰命名法,即第一个单词首字母小写,其它首字母大写。由于字段一般是私有的,命名上往往还会加上下划线前缀,如private string _myField
。
属性其实是上一篇中所提到的get/set访问器的外壳。它自己不承载数据,而是将字段暴露给外界进行读/写,更像是一种方法。属性对应的字段被称为属性的“后备变量”。属性和方法一样采用帕斯卡命名法,即每个单词的首字母都大写。
1 | private string _myField; |
由此延伸出的是自动属性,这是在属性的访问器不需要进行特别的定义时使用的。实际上,上面的例子就可以使用自动属性:
1 | public string MyProperty{ get; set; } |
自动属性当然不是没有对应的字段,而是由编译器自动创建了一个隐藏的后备变量。
使用属性而不是直接使用字段的一个好处是,你可以给get/set单独设置访问控制符,甚至可以不设置set访问器,使其成为只读属性:
1 | //使set访问器为private |
另外,还可以在用属性读写字段的过程中做一些别的操作,比如验证值的合法性,就不举例了。
¶常量const和只读readonly
被const
和readonly
修饰的字段都不能被修改,但它们也有区别。
const
常量是在编译时被编译器确定,用其值取代了所有使用到它的地方,类似于C语言的宏定义。
而readonly
的值是在运行时被构造函数决定的,它仍然是个变量,只不过它的值只能在构造函数中分配。对于引用类型,readonly
会使其自始至终只引用同一个实例,但实例本身可以修改。比如一个readonly
的List<T>
,不能在构造函数之外对其直接赋值,但是可以调用它的Add()方法,向其中添加元素。
1 | class Program |
这类似于C语言的指针常量:指针本身,即 “指针指向的是什么地址” 不能修改,但 “指向的地址上写了什么东西” 是可以修改的。
¶宏、条件编译
注意,C#没有宏定义这一机制!
确切地说,C#的预处理器不会像C语言一样,将源代码的内容根据宏定义进行替换。像#define ...
这样的预处理器指令,并不是用来进行宏定义的,它仅仅用于设置条件编译符号。而且,这些符号也不能像在C语言中一样被分配值。
条件编译符号还可以通过系统环境变量、编译器命令行选项提供。
条件编译符号的使用场景当然是条件编译。配合另外几条预处理器指令,可以选择一定情况下编译哪些代码:
- #if:开始条件编译代码块,仅在定义了指定的符号时,才会编译其中的代码。
- #elif:结束前面的条件编译代码块,并基于是否定义了指定的符号,开始一个新的条件编译代码块。
- #else:结束前面的条件编译代码块,如果没有定义前面指定的符号,开始一个新的条件编译代码块。
- #endif:结束前面的条件编译代码块。
- #undef:取消符号的定义。
比如:
1 | //注意符号的定义和取消要放在文件最前面 |
另外,还有通过特性attribute实现条件编译的方法。具体在下一篇的特性部分记录。