Appearance
面向对象
封装、继承、多态分别是什么?
一句话版
封装是隐藏内部细节,只暴露必要接口;
继承是子类复用和扩展父类;
多态是同一个调用在不同对象上表现出不同实现。
封装
封装就是把对象内部状态保护起来,不让外部随便改,只通过方法或属性控制访问。
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面试说法:
多态的核心是面向抽象编程,调用方只依赖父类或接口,具体行为由运行时对象决定。
加分总结
封装解决“怎么保护对象内部状态”;继承解决“怎么复用和扩展类型”;多态解决“怎么用统一接口调用不同实现”。实际开发里不要为了复用而滥用继承,很多业务场景优先组合加接口。
interface 和 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();
}核心区别
| 对比点 | interface | abstract 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。实际项目里常用接口做抽象边界,抽象类只承载少量公共骨架。
什么是虚函数?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(); // Dogvirtual
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# 中对应什么机制?
基类释放逻辑要能被派生类扩展,是为了防止“用基类引用释放派生类对象时,只释放了基类资源,派生类自己的资源没被释放”。
这在 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()保证基类资源也释放。
组合和继承怎么选择?
能稳定表达 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关系,并且需要多态,就用继承;如果只是拥有某种能力或依赖某个组件,就用组合。实际开发中默认优先组合,继承要更谨慎。
什么是里氏替换原则?
里氏替换原则是:父类能出现的地方,换成子类后,程序仍然应该正常工作,行为不应该被破坏。
c
void MakeSound(Animal animal)
{
animal.Speak();
}
MakeSound(new Dog());
MakeSound(new Cat());如果 Dog、Cat 都能按照 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() { }
}面试加分说法
继承不能只看现实世界的分类,还要看行为契约。子类不能比父类要求更多,不能比父类承诺更少,不能让调用方原本正确的代码变得不正确。
口诀:继承不只看“是不是”,还要看“能不能替换”。
什么是依赖倒置?
依赖倒置原则是:高层业务模块不要直接依赖低层具体实现,二者都应该依赖抽象。
更完整的说法:
高层模块不应该依赖低层模块,二者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。
不好的写法
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>();面试收尾:
依赖倒置的目的就是降低高层业务和底层细节的耦合。业务代码依赖接口,具体实现去实现接口,再通过构造函数注入进来。这样更容易替换实现、单元测试和扩展系统。
访问修饰符 public、private、protected、internal 区别是什么?
访问修饰符控制“谁能访问这个类型或成员”: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 有什么区别?
字段 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,对外暴露属性,这样既保留封装,又能以后加逻辑而不破坏调用方代码。
构造函数执行顺序是什么?
创建子类对象时:静态初始化先执行一次;实例字段初始化器先子后父;构造函数体先父后子。
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更完整地说:
- 如果需要,先做类型初始化:
static字段、static构造函数。每个类型只执行一次。 - 分配对象内存,实例字段先变成默认值,比如
int是0,引用类型是null。 - 执行实例字段初始化器:派生类字段初始化器先执行,再进入基类初始化流程。
- 执行构造函数体:先执行基类构造函数体,再执行派生类构造函数体。
常见面试说法可以简化成:
构造函数体是先父后子,因为子类构造前必须先构造父类部分。但字段初始化器在 C# 里要更细看,派生类的字段初始化器会先于基类构造函数体执行。
加分点:构造函数里不要调用 virtual 方法。因为此时对象还没完全构造好,虚调用可能进入子类重写方法,访问到还没准备好的状态。