C#复健——封装 继承 多态 抽象
参考文献
面向对象编程的弊端是什么? – invalid s的回答 – 知乎
https://www.zhihu.com/question/20275578/answer/26577791
强烈建议时不时回来看看参考文献提到的这篇文章 温故而知新
引
当我在一开始学习C#的时候 我对OOP的概念仍然是非常模糊的
只知道大家都这么干 所以我也这么干 至于为什么 并没有什么头绪
两年多了 事实上我对OOP的理解仍然没有加深多少
假如现在让我从头开始设计一个Buff系统 我会无从下手
于是打算由一篇文章重新学习和认识OOP和它的三大要素
C#中的OOP体现
我们都知道 C#是一门面向OOP的语言 设计上有很多地方是为了OOP而存在的
最经典的例子 就是Object 是所有类的基类 其它的所有类都是直接或间接继承于Object的
大家都有共同的一些方法和属性 例如Equals 用于判断两个对象是否是同一个
一些类还能比较是否是同一个值(例如特殊的string或者其它数据类型)
你可以重写这个方法来决定怎样才算匹配
封装
封装在C#中是基于访问修饰符来实现的
可以说 封装是为了多人合作和跨系统交流而生的
它规定了外部可以去访问的成员函数和数据 但不会影响到内部
这样就很容易的把责任划分开来 方便维护 也就是我们交流中的接口
(注意 这里是交流中的说法 C#中的接口另有含义)
只要我和你约定了这个接口是我们交流的唯一渠道
那么一旦出问题我修改接口内部的代码不会影响到外部你的实现
public void MakeAnimalSound(Animal animal) { animal.MakeSound(); }
例如我是这个MakeSound的作者 我对MakeSound的修改并不会影响到MakeAnimalSound这一方法
继承
基类(父类)和子类的关系就是来自于继承
当我们说一个类继承了另一个类的时候
子类会继承父类中的成员函数 根据需要沿用或者是重写
我们通常会遇到Protected修饰的情况 它允许子类去访问 却不允许外部去访问
这实际上也是一种封装 为了和子类所需的成员函数和数据区分开来
多态
重载函数不是OOP的多态 是编译上的多态 仅针对函数而言
同一个类里可以有若干重载函数 根据参数不同调用不同 但仅针对这个类而言
OOP的多态是多个类的 根据对象的不同 调用同一个外部方法 实际执行的逻辑不同
两者的核心思想是完全不同的
class Animal { public virtual void MakeSound() { Console.WriteLine("Animal makes a sound"); } } class Dog : Animal { public override void MakeSound() { Console.WriteLine("Dog barks"); } } class Cat : Animal { public override void MakeSound() { Console.WriteLine("Cat meows"); } } // 多态示例 Animal a1 = new Dog(); Animal a2 = new Cat(); a1.MakeSound(); // 输出 "Dog barks" a2.MakeSound(); // 输出 "Cat meows"
在上面这个例子中 狗和猫两个类都有发出声音这一方法
但实际执行的逻辑是完全不一样的 而且它们重写了父类的方法 并没有执行父类的部分
抽象
public abstract class Animal { public abstract void MakeSound(); }
只能作为基类 且 不能被实例化
所有继承这个类的子类都必须去实现abstract修饰的MakeSound这一方法
但注意 它仍然可以包括已经实现了的成员方法 而且不支持多继承
而且仍然有构造函数
这也是它和我们下面要讲的接口的区别
接口
接口是一个完全抽象的东西 不包含任何实现 所以也没有构造函数
但是一个类可以实现多个接口 起到类似多继承的效果
public interface ISoundMaker { void MakeSound(); } public class Animal : ISoundMaker { public virtual void MakeSound() { Console.WriteLine("The animal makes a generic sound"); } } public class Dog : Animal { public override void MakeSound() { Console.WriteLine("The dog barks"); } } public class Cat : Animal { public override void MakeSound() { Console.WriteLine("The cat meows"); } }
可以看到 在上面这段代码中 我们又加了一层
发出声音现在是由ISoundMaker这个接口控制的
但是 我们加了一层接口 又有什么不一样呢?
在现在的结构下来看似乎和前面直接继承然后重写虚函数并无不同
实现继承和接口继承
首先我们不得不提一个问题 C#里是不支持多继承的
对于Class Inheritance(类继承 或 实现继承)来说
缺点很明显 父类和子类是一种强耦合的关系
假如你并没有重写父类的成员方法 那么对父类的影响会影响到子类
同时单继承会让代码不够灵活 继承可能会变得无穷无尽
与此相对的Interface Inheritance(接口继承)
就是一种松耦合的关系 Unity中比较常见的例子就是拖拽三接口
不管是什么对象 只要继承了拖拽三接口并实现了它 就可以拖拽
也就是说 我们只关心它是可拖拽物体这一事实
缺点就是没办法代码重用 因为接口本身不提供实现 只提供规范
当讨论到这里的时候 我们就会发现问题所在了
应用过程中有很多很多种情况 什么时候该用哪一种呢?
我可不可以混着去使用它呢?
抽象思想
本来我也想用归一化这个词来作为概括
但我发现数据处理其实已经把这个词给用完了
现在直接用搜索引擎搜出来的都是跟数据处理有关的
所以我决定还是沿用抽象这一概念来描述
数据表的抽象体现
我们现在来讨论另外一个问题
假如我们现在要实现一个RPG游戏的技能系统
通常情况下数量会比较多 一个角色可能有普攻和大招两种技能
假设我们有十几种职业/角色 那就有2*10 也就是二十种技能
那是否我需要一个Skill基类 和20个继承这一基类的派生技能类呢?
事实上很多游戏并不是这么做的 它们把技能的各个部分都给抽象出来
用一个技能类便能够完整的描述所有技能
技能名称 技能ID 技能类型 释放对象 释放范围 造成效果(Buff)造成伤害
当然这只是我随便举的一个例子 实际设计有很多可以再讨论的地方
例如伤害可不可以是一个Buff 持续伤害可不可以是一个Buff
这些数据会以表的形式存储在游戏数据中 在运行时反持久化出来使用
这一套就大大简便了复杂的技能配置需求 足够灵活 最关键的是
它还把实现和数据分离了出来 这样策划就不用关心内部实现
只需要考虑配出来的效果是否符合预期 如果不符合再由程序去修正
这仍然会有问题(程序很可能get不到策划想要的 于是无止尽的讨论开始了)
但已经大大方便了设计和实现 这便是抽象思想的体现
PS:实际操作过程中很可能是先有数据表 才有实体类
接口继承的抽象体现
我们再来讲讲另一个问题 Buff 我们知道Buff其实和技能一样都十分复杂
这次我们在这里尝试另一种方法 用接口继承去实现它
public interface IBuff { void ApplyEffect(Character character); void RemoveEffect(Character character); }
我们再来定义两个不同类型的Buff
public class HealthBuff : IBuff { private int healthBonus; public HealthBuff(int bonus) { healthBonus = bonus; } public void ApplyEffect(Character character) { character.IncreaseHealth(healthBonus); } public void RemoveEffect(Character character) { character.DecreaseHealth(healthBonus); } } public class DamageBuff : IBuff { private int damageBonus; public DamageBuff(int bonus) { damageBonus = bonus; } public void ApplyEffect(Character character) { character.IncreaseDamage(damageBonus); } public void RemoveEffect(Character character) { character.DecreaseDamage(damageBonus); } }
然后我们需要去实现一个角色实体
public class Character { private int health; private int damage; private List<IBuff> activeBuffs = new List<IBuff>(); public Character(int initialHealth, int initialDamage) { health = initialHealth; damage = initialDamage; } public void ApplyBuff(IBuff buff) { buff.ApplyEffect(this); activeBuffs.Add(buff); } public void RemoveBuff(IBuff buff) { buff.RemoveEffect(this); activeBuffs.Remove(buff); } public void IncreaseHealth(int amount) { health += amount; } public void DecreaseHealth(int amount) { health -= amount; } public void IncreaseDamage(int amount) { damage += amount; } public void DecreaseDamage(int amount) { damage -= amount; } }
到这里 我们就算实现了一个非常简单的Buff系统 现在我们再来和上面的技能所讲到的东西对比一下
可以发现 虽然这也是一种抽象 但你需要一直去添加新的类 数量少确实方便 但多了确实不是很好的方法
当然这不是重点 我们要说的OOP的运用并不都是好处 还是要根据具体情况来分析
OOP的陷阱
类的继承和多态的滥用不会使程序变得更易读
这实际上是我自己也深感痛苦的一点
首先先叠甲 具体问题具体分析 很多的时候不适当的抽象会导致这种问题
当我拆解别人的代码的时候 经常会很痛苦 因为一个简单的API拆上去 却有好几个父类和接口
对于我这种不太聪明的人来说 想要搞清楚这其中的继承关系是很痛苦的
而命名方式和实现不匹配所带来的关于多态的一些误解我就不说了
过度封装不会使数据交流变得更便捷
你有没有遇到一个数据或成员函数想要使用
却发现它是个private成员的情况
事实上我在之前的工作中经常会遇到这个问题
当然 这跟需求的不断变动也有关系
(这又是另一个话题了 敏捷开发的痛苦)
C#的自动属性 只读属性等等 更是提供了更自由地选择
但这也引入新的问题:每一个字段都需要配备一个属性吗?
当然不是 公共接口 只读的字段(Readonly修饰)私有的字段
完全都不需要属性再封装一层 只有需要数据验证和控制的情况才需要属性
我们需要意识到 过度的封装只会增加痛苦 对于需求上确信是公共的
大可以放心大胆的作为一个Public的字段使用 而无需其它修饰
总结
其实一句话就可以概括 适当的抽象 谨慎的选择
使用OOP并不意味着你的代码就一定继承了OOP的优良特性
良好的抽象才决定了OOP的可读性