Quick Reference
상속은 파생 타입이 기반 타입의 상태와 계약을 그대로 대체할 수 있을 때 씁니다. 기반 생성자의 입력이 필요하면 : base(...)로 전달하고, 달라질 수 있는 동작은 virtual/override, 반드시 구현할 동작은 abstract로 선언합니다.
public abstract class Shape(string color)
{
public string Color { get; } = color;
public abstract double Area();
public virtual string Describe() => $"{Color}, {Area():F2}";
}
public sealed class Circle(double radius) : Shape("blue")
{
public override double Area() => Math.PI * radius * radius;
}- 공통 상태·보호된 확장 지점이 함께 필요하면
abstract class를 고릅니다. - 서로 다른 계층에 붙는 기능 계약이면
interface, 기능을 조립하거나 교체하면composition을 고릅니다. - 생성자 안에서는
virtual또는abstract멤버를 호출하지 않습니다. 파생 타입의 초기화가 아직 끝나지 않았을 수 있습니다.
상속 표면
| 선언 | 기반 타입의 구현 | 파생 타입의 의무 | 쓸 때 |
|---|---|---|---|
| 일반 멤버 | 있음 | 그대로 사용 | 확장 지점이 아닌 공통 동작 |
virtual | 있음 | 필요할 때 override | 기본 동작을 안전하게 바꿀 수 있을 때 |
abstract | 없음 | non-abstract 파생 타입은 override | 구현 방식마다 반드시 달라질 때 |
sealed class | 해당 없음 | 더 이상 파생 불가 | 상속을 지원하지 않겠다는 API 정책일 때 |
sealed override | 현재 구현 | 그 아래 재정의 불가 | 특정 확장 지점을 여기서 닫을 때 |
파생 클래스는 직접 기반 클래스 하나만 둘 수 있고, 기반 클래스의 생성자와 finalizer는 상속하지 않습니다. base.Member()는 부모 구현을 호출하고, base(...)는 파생 생성자가 시작되기 전에 부모 상태를 만들 때 씁니다.
public class Animal(string name)
{
public string Name { get; } = name;
public virtual string Speak() => "...";
}
public class Dog(string name) : Animal(name)
{
public override string Speak() => "Woof";
}
public class GuardDog(string name) : Dog(name)
{
public sealed override string Speak() => base.Speak() + "!";
}같은 이름의 멤버에 new를 붙이면 재정의가 아니라 숨김입니다. 어느 멤버를 호출할지는 런타임 객체가 아니라 변수의 정적 타입으로 정해지므로, 기반 계약을 바꾸려는 목적에는 new를 쓰지 않습니다.
생성과 확장 경계
생성자는 객체의 불변 조건을 만드는 곳입니다. 기반 클래스는 파생 타입의 필드나 생성자 본문이 준비되었다고 가정할 수 없으므로, overridable member 호출을 생성자에서 피해야 합니다.
public abstract class Report
{
protected Report()
{
// Render(); // 위험: 파생 상태가 아직 준비되지 않았을 수 있음
}
public abstract string Render();
}
public sealed class SalesReport(string region) : Report
{
public override string Render() => $"Sales: {region}";
}파생 클래스가 지켜야 할 규칙이 있다면 protected 필드를 열어 두기보다, 입력을 검증하는 protected 메서드나 private set 상태처럼 좁은 확장 표면을 둡니다. 한 번 public/protected virtual로 공개한 멤버는 파생 클래스가 의존할 수 있으므로 이후 변경 비용이 큽니다.
상속을 고르는 기준
// Player는 Logger가 아니다. 기능 재사용은 합성으로 둡니다.
public sealed class Player(ILogger logger)
{
public void Save() => logger.Log("saved");
}is-a관계와 대체 가능성이 명확하고 공통 상태를 기반 타입이 소유하면 상속이 맞습니다.- 여러 종류의 타입이 같은 능력을 제공하면
interface가 맞습니다. 자세한 계약은 interface 기본에서 다룹니다. - 동작을 런타임에 바꾸거나 일부 기능만 재사용하면 합성이 더 안전합니다.
sealed은 최적화 힌트가 아니라 확장 정책입니다. JIT의 최적화 여부를 프로그램 계약으로 기대하지 말고, 상속을 허용할지 여부로만 판단합니다.
자주 틀리는 부분
기반 타입의 virtual을 파생 생성자에서 호출해도 안전해지지 않습니다. 문제는 호출 위치가 아니라 객체 초기화 중에 외부 확장 코드가 실행되는 것입니다.
인터페이스만 필요했던 관계를 상속으로 만들면 기반 클래스의 변경과 상태 규칙까지 함께 물려받습니다. 반대로 공통 상태·생성 규칙이 필요한 타입을 interface만으로 묶으면 구현이 흩어질 수 있습니다.
참고 링크
2 sources