1. 多态不是魔法
刚开始学的时候,多态在C#里看起来就像是编程的胜利:继承、重写、用基类类型调用——一切都很顺。但实际用起来有不少细节。我们来仔细看看。
方法隐藏和new关键字
假设我们有一个基类和一个派生类,它们都有同名方法,但没有用override。如果在派生类里重新定义了这个方法但没用override,那它其实是把基类的方法“藏起来”了,而不是重写。编译器会像老妈一样唠叨你,建议你明确写上new:
class Animal
{
public void Speak()
{
Console.WriteLine("动物发出声音。");
}
}
class Cat : Animal
{
public new void Speak()
{
Console.WriteLine("喵!");
}
}
// 使用
Animal animal = new Cat();
animal.Speak(); // 输出: "动物发出声音。"
哇!即使我们创建了Cat类型的对象并把它放到Animal类型的变量里,调用的还是基类的原始方法。为啥?因为这个方法不是虚方法!第一个坑:想用多态,别忘了virtual和override。只有你真的想隐藏方法(其实很少有这种需求)才用new。
构造函数和多态的调用
还有个不太明显的点:构造函数不是虚的。如果你在基类里写了构造函数,派生类里也有自己的,那它们不会多态。比如:
class Animal
{
public Animal()
{
Console.WriteLine("Animal构造函数");
}
}
class Cat : Animal
{
public Cat()
{
Console.WriteLine("Cat构造函数");
}
}
// 使用
Animal animal = new Cat();
// 输出:
// Animal构造函数
// Cat构造函数
但如果你在基类构造函数里调用了可以被重写的方法,结果可能很迷——虚方法会在子类初始化前被调用!所以别在构造函数里调用虚/抽象方法。
override时封装被“破坏”的问题
虚方法很香,但如果你在基类里假定某个方法行为很固定,结果子类重写后把逻辑搞乱了——就会有奇怪的bug。
class Animal
{
public virtual void Eat()
{
Console.WriteLine("动物在吃东西。");
}
public void Live()
{
Eat(); // 可能会调用任何重写的版本!
}
}
class Cat : Animal
{
public override void Eat()
{
Console.WriteLine("猫在吃鱼。");
}
}
Animal a = new Cat();
a.Live(); // 输出 "猫在吃鱼。"
如果基类Animal的Eat()输出“动物在吃东西”,结果子类重写Eat()后加了危险操作,整个类的工作就可能崩了。这就是Liskov替换原则(Liskov Substitution Principle, LSP)的问题。设计时一定要想清楚,子类的行为是不是和基类逻辑一致。
类型转换和类型判断的坑
多态让你能把不同对象放在一个“堆”里:比如一个基类类型的列表,里面有狗有猫(都继承自Animal)。但如果你想调用某些特有方法:
List<Animal> pets = new List<Animal> { new Cat(), new Dog() };
foreach (var pet in pets)
{
if (pet is Cat cat)
{
cat.Purr();
}
}
如果你忘了类型判断,直接强转,就会遇到InvalidCastException。有时候这会让你代码里到处都是类型判断,变得很乱,也说明你的设计可能要重构了。
2. 抽象的问题
抽象是让用户用对象更简单、限制内部状态访问的好工具。但这里也有坑!
抽象层级太多(Over-Abstraction)
有些新手(甚至老手)太迷信“正宗OOP”,搞出一堆基类、接口、抽象层,最后连自己都看不懂。
interface IAnimal
{
void Speak();
}
abstract class Feline : IAnimal
{
public abstract void Speak();
}
class Cat : Feline
{
public override void Speak()
{
Console.WriteLine("喵!");
}
}
你看,中间那个抽象类啥都没加,有啥用?纯粹为了抽象而抽象只会让维护和架构更难。
不合理的继承层级
想想如果你把某个操作放到继承树顶层,但其实不是所有子类都适合:
abstract class Animal
{
public abstract void Fly();
}
class Cat : Animal
{
public override void Fly()
{
throw new NotImplementedException("猫不会飞!");
}
}
你要么在子类里写一堆抛异常的假实现,要么忍受你的接口不符合现实。这就是典型的“错误继承结构”反模式。这种情况最好把方法拆到独立接口里(比如IFlyable)。
建议:别试图做一个“万能抽象”。
抽象类和API变更的坑
一旦抽象类上线并被继承,任何改动都很危险。加一个新的抽象方法,所有子类都得实现——否则代码直接编译不过。这让库和公开API的维护变得很难。
这也是为啥有了带默认实现的接口(Default Interface Methods——见第116讲):这样可以扩展接口而不用立刻改所有代码。
抽象时封装被破坏
你把类做成抽象的,通常要把成员声明成protected,让子类能访问。但这样经常会泄露内部逻辑,其实最好隐藏。结果就是子类能访问和操作本不该动的东西,可能破坏基类的内部一致性。
3. 实战中的错误场景
别以为这些坑只会出现在作业里,现实中老鸟也会踩。
没把方法声明成virtual的例子
比如我们扩展自己的日志小程序(见第24天)。有个基类logger:
class BaseLogger
{
public void Log(string message)
{
Console.WriteLine(message);
}
}
class FileLogger : BaseLogger
{
public void Log(string message)
{
// 写入文件
Console.WriteLine("写入文件: " + message);
}
}
// 使用:
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // 期望: "写入文件: Hello!",实际: "Hello!"
FileLogger的作者以为自己重写了方法,其实忘了加override,基类方法也没声明成虚的。结果调用的还是基类版本。
建议:要重写的方法,基类一定要virtual,子类一定要override。
抽象设计不当的例子:“灵活”的动物
继续动物话题!我们单独搞个IFlyable接口,这样不用让所有动物都实现Fly方法:
interface IFlyable
{
void Fly();
}
class Bird : IFlyable
{
public void Fly() => Console.WriteLine("鸟在飞!");
}
class Cat
{
// 猫不实现IFlyable
}
现在可以写个只处理“会飞的”的函数,猫就不用管了:
void MakeItFly(object creature)
{
if (creature is IFlyable flyingThing)
{
flyingThing.Fly();
}
else
{
Console.WriteLine("这家伙不会飞。");
}
}
这样就不用用假抽象方法污染架构了。
“死板”抽象类导致扩展难的例子
假设你发布了一个带抽象类的库:
public abstract class Creature
{
public abstract void DoAction();
}
用户开始继承写自己的类。一年后你想扩展API,加了:
public abstract class Creature
{
public abstract void DoAction();
public abstract void Sleep(); // 新方法!
}
现在所有用户的类都编译不过了,因为必须实现新方法。这就是设计抽象时要小心,能用带默认实现的接口就用。
4. 避坑建议
让你的应用像体操运动员一样灵活,但别把自己摔断腿!
- 别滥用继承:能用组合(把一个对象嵌到另一个里)就用组合。
- 只有确定要重写的方法才声明成虚的。
- 别写没意义的抽象类,也别搞“为将来”准备的继承树。
- 重写方法时一定要保证逻辑正确,别破坏基类的规则(不变式)。
- 库发布后别往公开基类和接口里加新抽象方法。
- 用接口实现程序各部分的松耦合。
- 扩展API时用Default Interface Methods。
GO TO FULL VERSION