CodeGym /课程 /C# SELF /多态和抽象的问题

多态和抽象的问题

C# SELF
第 25 级 , 课程 3
可用

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类型的变量里,调用的还是基类的原始方法。为啥?因为这个方法不是虚方法!第一个坑:想用多态,别忘了virtualoverride。只有你真的想隐藏方法(其实很少有这种需求)才用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(); // 输出 "猫在吃鱼。"

如果基类AnimalEat()输出“动物在吃东西”,结果子类重写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。
2
任务
C# SELF, 第 25 级, 课程 3
已锁定
使用关键字 `new` 隐藏方法
使用关键字 `new` 隐藏方法
2
任务
C# SELF, 第 25 级, 课程 3
已锁定
使用抽象类和专门化原则
使用抽象类和专门化原则
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION