Skip to content

面向对象

封装、继承、多态分别是什么?

oop-encapsulation-inheritance-polymorphism

一句话版

封装是隐藏内部细节,只暴露必要接口;

继承是子类复用和扩展父类;

多态是同一个调用在不同对象上表现出不同实现。

封装

封装就是把对象内部状态保护起来,不让外部随便改,只通过方法或属性控制访问。

c
class Account
{
    private decimal balance;

    public void Deposit(decimal amount)
    {
        if (amount <= 0) return;
        balance += amount;
    }

    public decimal Balance => balance;
}

面试说法:

封装的核心是隐藏实现细节,保护数据一致性,对外提供稳定 API。

继承

继承表示一种 is-a 关系,子类可以复用父类成员,也可以扩展自己的行为。

c
class Animal
{
    public string Name { get; set; }

    public void Eat()
    {
        Console.WriteLine("Eating");
    }
}

class Dog : Animal
{
    public void Bark()
    {
        Console.WriteLine("Bark");
    }
}

面试说法:

继承用于表达父子类型关系和代码复用。C# 中类只能单继承,但可以实现多个接口。

多态

多态就是用父类或接口引用指向不同子类对象,调用同一个方法时,运行时执行不同实现。

c
class Animal
{
    public virtual void Speak()
    {
        Console.WriteLine("Animal sound");
    }
}

class Dog : Animal
{
    public override void Speak()
    {
        Console.WriteLine("Bark");
    }
}

class Cat : Animal
{
    public override void Speak()
    {
        Console.WriteLine("Meow");
    }
}

Animal a = new Dog();
a.Speak(); // Bark

面试说法:

多态的核心是面向抽象编程,调用方只依赖父类或接口,具体行为由运行时对象决定。

加分总结

封装解决“怎么保护对象内部状态”;继承解决“怎么复用和扩展类型”;多态解决“怎么用统一接口调用不同实现”。实际开发里不要为了复用而滥用继承,很多业务场景优先组合加接口。

interfaceabstract class 区别是什么?

interface-vs-abstract-class

一句话版

interface 定义“能做什么”,偏能力契约;abstract class 定义“是什么”,偏共同父类和共享实现。

c
interface IFlyable
{
    void Fly();
}

abstract class Animal
{
    public string Name { get; set; }

    public void Eat()
    {
        Console.WriteLine("Eating");
    }

    public abstract void Speak();
}

核心区别

对比点interfaceabstract class
关系含义能力/契约,can-do父子关系,is-a
多继承一个类可实现多个接口一个类只能继承一个基类
状态通常不保存实例状态可以有字段、属性、构造函数
方法实现主要定义契约;现代 C# 可有默认实现可有普通方法、虚方法、抽象方法
构造函数没有实例构造函数可以有构造函数
使用场景解耦、插件、依赖倒置、横向能力共享代码、共享状态、模板方法

例子:接口

c
interface ILogger
{
    void Log(string message);
}

class FileLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine($"File: {message}");
    }
}

class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine($"Console: {message}");
    }
}

这里 ILogger 只关心“你会不会记录日志”,不关心你到底是文件日志还是控制台日志。

例子:抽象类

c
abstract class Animal
{
    public string Name { get; set; }

    public void Eat()
    {
        Console.WriteLine($"{Name} is eating");
    }

    public abstract void Speak();
}

class Dog : Animal
{
    public override void Speak()
    {
        Console.WriteLine("Bark");
    }
}

这里 Dog 确实是一个 Animal,并且可以复用 Animal 里的公共状态和行为。

面试收尾

如果只是定义能力或规范,用 interface;如果需要共享字段、构造逻辑、公共方法,并且确实存在父子关系,用 abstract class。实际项目里常用接口做抽象边界,抽象类只承载少量公共骨架。

什么是虚函数?virtualoverridenew 区别是什么?

virtual-override-new

虚函数就是父类中用 virtual 标记、允许子类用 override 重写的方法;

调用时会根据运行时真实对象决定执行哪个版本,这就是多态。

c
class Animal
{
    public virtual void Speak()
    {
        Console.WriteLine("Animal");
    }
}

class Dog : Animal
{
    public override void Speak()
    {
        Console.WriteLine("Dog");
    }
}

Animal a = new Dog();
a.Speak(); // Dog

virtual

virtual 写在父类方法上,表示这个方法允许被子类重写。

c
public virtual void Speak() {}

override

override 写在子类方法上,表示真正重写父类的虚方法,参与运行时多态。

c
public override void Speak() {}

所以:

c
Animal a = new Dog();
a.Speak(); // 看运行时真实类型 Dog,调用 Dog.Speak()

new

new 不是重写,而是隐藏父类同名成员。调用哪个方法主要看变量的声明类型。

c
class Animal
{
    public void Speak()
    {
        Console.WriteLine("Animal");
    }
}

class Dog : Animal
{
    public new void Speak()
    {
        Console.WriteLine("Dog");
    }
}

Animal a = new Dog();
a.Speak(); // Animal

Dog d = new Dog();
d.Speak(); // Dog

面试收尾:

virtual 是父类开放重写点,override 是子类重写并参与多态,new 是隐藏父类成员,不参与同一个虚方法分派。

简单记:override 看运行时对象,new 看编译期变量类型。

为什么基类析构/释放逻辑要设计成虚的?C# 中对应什么机制?

virtual-dispose-finalizer

基类释放逻辑要能被派生类扩展,是为了防止“用基类引用释放派生类对象时,只释放了基类资源,派生类自己的资源没被释放”。

这在 C++ 里通常说:基类析构函数要设计成 virtual

在 C# 里对应的重点不是把“析构函数”写成虚的,而是使用:

c
IDisposable + protected virtual Dispose(bool disposing)

典型写法:

c
class BaseResource : IDisposable
{
    private bool disposed;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposed) return;

        if (disposing)
        {
            // 释放托管资源
        }

        // 释放非托管资源

        disposed = true;
    }

    ~BaseResource()
    {
        Dispose(false);
    }
}

class DerivedResource : BaseResource
{
    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            // 释放派生类托管资源
        }

        // 释放派生类非托管资源

        base.Dispose(disposing);
    }
}

重点解释:

  • Dispose() 是给使用者手动调用的释放入口。
  • Dispose(bool disposing) 设计成 protected virtual,让子类可以 override
  • disposing == true 表示手动释放,可以释放托管资源和非托管资源。
  • disposing == false 表示 GC 终结器兜底调用,通常只释放非托管资源。
  • 派生类重写后,要调用 base.Dispose(disposing),保证基类资源也被释放。

C# 里的 ~ClassName()终结器 finalizer,不是 C++ 那种确定时机执行的析构函数。它由 GC 在对象不可达后某个时间调用,时间不确定,所以普通资源释放不要依赖它,应该优先用:

c
using var resource = new MyResource();

面试收尾:

C++ 中基类析构函数要虚,是为了通过基类指针删除派生类对象时能正确调用派生类析构。C# 中对应的是 IDisposable 释放模式:公共 Dispose() 调用 protected virtual Dispose(bool),让派生类重写释放自己的资源,同时通过 base.Dispose() 保证基类资源也释放。

组合和继承怎么选择?

composition-vs-inheritance

能稳定表达 is-a 关系,并且需要多态时,用继承;只是复用能力、持有依赖、拼装行为时,优先用组合。

继承适合什么

继承表示“子类是父类的一种”。

c
abstract class Animal
{
    public abstract void Speak();
}

class Dog : Animal
{
    public override void Speak()
    {
        Console.WriteLine("Bark");
    }
}

这里 Dog is an Animal 很自然,而且可以用父类引用统一调用:

c
Animal animal = new Dog();
animal.Speak();

适合继承的场景:

  • 父子关系稳定
  • 子类确实可以替代父类,符合里氏替换原则
  • 需要 virtual/override 做多态
  • 父类提供少量稳定公共骨架

组合适合什么

组合表示“一个对象拥有另一个对象”或“依赖某种能力”。

c
interface IPaymentService
{
    void Pay(decimal amount);
}

class OrderService
{
    private readonly IPaymentService paymentService;

    public OrderService(IPaymentService paymentService)
    {
        this.paymentService = paymentService;
    }

    public void Submit(decimal amount)
    {
        paymentService.Pay(amount);
    }
}

这里 OrderService 不是 PaymentService,只是“使用支付能力”,所以组合更合适。

适合组合的场景:

  • 只是复用代码,不是父子关系
  • 行为需要替换,比如微信支付、支付宝支付、银行卡支付
  • 依赖需要注入,方便测试
  • 类层级可能变复杂,想降低耦合

面试加分点

不要为了复用代码就继承。继承会把父类和子类强绑定,父类变化可能影响所有子类。组合更灵活,也更符合“组合优于继承”的工程经验。

收尾可以这样说:

判断标准是:如果对象之间是稳定的 is-a 关系,并且需要多态,就用继承;如果只是拥有某种能力或依赖某个组件,就用组合。实际开发中默认优先组合,继承要更谨慎。

什么是里氏替换原则?

liskov-substitution-principle

里氏替换原则是:父类能出现的地方,换成子类后,程序仍然应该正常工作,行为不应该被破坏。

c
void MakeSound(Animal animal)
{
    animal.Speak();
}

MakeSound(new Dog());
MakeSound(new Cat());

如果 DogCat 都能按照 Animal 的约定正常工作,就符合里氏替换原则。

反例

c
class Bird
{
    public virtual void Fly() { }
}

class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException();
    }
}

从现实看,企鹅是鸟;但从程序行为看,如果代码认为 Bird 都能 Fly(),换成 Penguin 就炸了,这就违反里氏替换原则。

更好的设计:

c
interface IFlyable
{
    void Fly();
}

class Sparrow : IFlyable
{
    public void Fly() { }
}

class Penguin
{
    public void Swim() { }
}

面试加分说法

继承不能只看现实世界的分类,还要看行为契约。子类不能比父类要求更多,不能比父类承诺更少,不能让调用方原本正确的代码变得不正确。

口诀:继承不只看“是不是”,还要看“能不能替换”。

什么是依赖倒置?

dependency-inversion-principle

依赖倒置原则是:高层业务模块不要直接依赖低层具体实现,二者都应该依赖抽象。

更完整的说法:

高层模块不应该依赖低层模块,二者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。

不好的写法

c
class MySqlOrderRepository
{
    public void Save() { }
}

class OrderService
{
    private readonly MySqlOrderRepository repo = new MySqlOrderRepository();

    public void Submit()
    {
        repo.Save();
    }
}

问题是 OrderService 直接依赖 MySqlOrderRepository。以后要换成 SQL Server、Redis、Mock 测试仓储,都要改业务类。

更好的写法

c
interface IOrderRepository
{
    void Save();
}

class MySqlOrderRepository : IOrderRepository
{
    public void Save() { }
}

class OrderService
{
    private readonly IOrderRepository repo;

    public OrderService(IOrderRepository repo)
    {
        this.repo = repo;
    }

    public void Submit()
    {
        repo.Save();
    }
}

现在 OrderService 只依赖接口 IOrderRepository,不关心具体实现是谁。

依赖注入的关系

依赖倒置是原则,依赖注入是常见实现方式。

c
var repo = new MySqlOrderRepository();
var service = new OrderService(repo);

或者用容器:

c
services.AddScoped<IOrderRepository, MySqlOrderRepository>();

面试收尾:

依赖倒置的目的就是降低高层业务和底层细节的耦合。业务代码依赖接口,具体实现去实现接口,再通过构造函数注入进来。这样更容易替换实现、单元测试和扩展系统。

访问修饰符 publicprivateprotectedinternal 区别是什么?

access-modifiers

访问修饰符控制“谁能访问这个类型或成员”:private 最小,public 最大。

修饰符访问范围常见用途
private只能当前类内部访问封装字段、隐藏实现细节
protected当前类和子类可访问给派生类扩展用
internal当前程序集内可访问项目/类库内部共享
public任何地方都可访问对外公开 API

例子:

c
public class User
{
    private int id;              // 只有 User 类内部能访问

    protected string name;       // User 和子类能访问

    internal void Load()         // 同一个程序集能访问
    {
    }

    public void Save()           // 任何地方都能访问
    {
    }
}

面试加分点:

c
class Demo
{
    int x; // 默认是 private
}

类成员默认是 private

c
class User
{
}

顶级类型默认是 internal,也就是只在当前程序集可见。

再补两个组合修饰符:

  • protected internal:子类可以访问,或者同程序集可以访问,范围是“并集”。
  • private protected:只有同程序集内的子类可以访问,范围更小,是“交集”。

收尾可以这样说:

访问修饰符本质是封装边界。默认应该尽量收紧,能 private 就不要 public,只把真正需要暴露的 API 开出去。

属性 Property 和字段 Field 有什么区别?

property-vs-field

字段 Field 是真正存数据的变量;属性 Property 是对外访问数据的入口,本质上是 get / set 方法。

c
class User
{
    private int age; // 字段 Field

    public int Age   // 属性 Property
    {
        get { return age; }
        set
        {
            if (value < 0) throw new ArgumentException();
            age = value;
        }
    }
}

字段通常用于类的内部状态:

c
private string name;
private int age;

属性通常用于对外暴露:

c
public string Name { get; set; }
public int Age { get; private set; }

属性的好处是可以加逻辑,比如校验、只读控制、延迟加载、触发通知:

c
public int Age
{
    get => age;
    set
    {
        if (value < 0) return;
        age = value;
    }
}

自动属性:

c
public string Name { get; set; }

它看起来没有字段,但编译器会自动生成一个隐藏的 backing field。

面试收尾:

Field 是数据存储,Property 是访问封装。实际开发中通常字段设为 private,对外暴露属性,这样既保留封装,又能以后加逻辑而不破坏调用方代码。

构造函数执行顺序是什么?

constructor-execution-order

创建子类对象时:静态初始化先执行一次;实例字段初始化器先子后父;构造函数体先父后子。

c
class Base
{
    int b = Log("Base field");

    public Base()
    {
        Log("Base ctor");
    }
}

class Derived : Base
{
    int d = Log("Derived field");

    public Derived()
    {
        Log("Derived ctor");
    }
}

执行:

c
new Derived();

典型顺序是:

c
Derived field
Base field
Base ctor
Derived ctor

更完整地说:

  1. 如果需要,先做类型初始化:static 字段、static 构造函数。每个类型只执行一次。
  2. 分配对象内存,实例字段先变成默认值,比如 int0,引用类型是 null
  3. 执行实例字段初始化器:派生类字段初始化器先执行,再进入基类初始化流程。
  4. 执行构造函数体:先执行基类构造函数体,再执行派生类构造函数体。

常见面试说法可以简化成:

构造函数体是先父后子,因为子类构造前必须先构造父类部分。但字段初始化器在 C# 里要更细看,派生类的字段初始化器会先于基类构造函数体执行。

加分点:构造函数里不要调用 virtual 方法。因为此时对象还没完全构造好,虚调用可能进入子类重写方法,访问到还没准备好的状态。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0