C#

C# 객체지향 문법 [다형성]

devrabbit22 2025. 5. 17. 22:22

객체지향의 4대 특징은 일반적으로 추상화, 캡슐화, 상속, 다형성이다.

객체지향 언어에서 보통 추상화는 '클래스'를 통해 제공된다.

다형성(polymorphism)은 '여러가지 형태를 띈다'는 것인데 메서드 오버라이드와 메서드 오버로드를 통해 다형성에 대해 공부할 수 있다.

메서드 오버라이드

현실 세계를 객체지향 개념을 이용해 모델링하면, 포유류 안에 사자, 고래, 인간 등이 포함된다.

ex)

class Mammal
{
    public void Move()
    {
        Console.WriteLine("이동한다.");
    }
}

class Lion : Mammal
{
}
class Whale : Mammal
{
}
class Human : Mammal
{
}

그런데 객체들이 움직이는 방법이 각각 다르다. 

Lion은 네 발로 뛰고 고래는 수영하며, 인간은 두 발로 움직이다.

Move라는 특징은 공유하지만, 자식들의 행동 방식이 다르므로 각자 움직임을 재정의 해야한다.

class Lion : Mammal
{
    public void Move()
    {
        Console.WriteLine("네 발로 움직인다.");
    }
}
class Whale : Mammal
{
    public void Move()
    {
        Conosle.WriteLine("수영한다.");
    }
}
class Human : Mammal
{
    public void Move()
    {
        Console.WriteLine("두 발로 움직인다.");
    }
}

이렇게 부모/자식 클래스가 만들어지면 기본적인 사용에 있어서는 문제가 될 것이 없다.

Mammal one = new Mammal();
one.Move();

Lion lion = new Lion();
lion.Move();

Whale whale = new Whale();
whale.Move();

Human human = new Human();
human.Move();

자식이 부모 타입으로 암시적 형 변환이 된 경우?

Lion lion = new Lion();
Mammal one = lion;	//부모 타입으로 형 변환
one.Move();

부모 타입으로 형 변환되긴 했지만, 원래의 인스턴스 자체는 Lion 타입이므로 의도했던 동작이 아니다.

기본적으로 Lion 인스턴스가 이동했기 때문에 Lion 클래스의 Move가 호출되아 하하고, 결과는 '네 발로 움직인다.'가 출력되어야 한다. 하지만 이동한다. 라는 문구가 출력되기 때문에

이런 문제를 해결하기 위해 가상 메서드(virutual method)라는 것이 제공된다.

일반 메서드를 가상 메서드로 바꾸려면 virtual이라는 예약어를 부모 클래스 단계에서 명시해야 한다.

class Mammal
{
    virtual public void Move()
    {
        COnsole.WriteLine("이동한다.");
    }
}

자식 클래스에서 해당 메서드가 다형성을 띄도록 명시적으로 override 예약어를 지정하기만 하면 된다.

class Lion : Mammal
{
    virtual public void Move()
    {
        Console.WriteLine("네 발로 움직인다.");
    }
}
class Whale : Mammal
{
    override public void move()
    {
        Console.WriteLine("수영한다.");
    }
}
class Human : Mammal
{
    override public void Move()
    {
        Console.WriteLine("두 발로 움직인다.");
    }
}

부모 클래스의 메서드에는 virtual을 적용하고 자식 클래스의 메서드에는 override를 적용해 의도한 대로 동작하는 것을 확인 가능

Lion lion = new Lion();
Mammal one = lion;	//부모 타입으로 형 변환
one.Move();

Human human = new Human();
Mammal two = human;
two.Move();

Move 메서드는 부모와 자식 클래스에서 이름만 같았을 뿐 전혀 상관없는 동작을 개별 클래스에서 정의한 것이나 다름 없다.

virtual/override 예약어를 적용함으로써 부모에서 정의한 Move라는 하나의 동작에 대해 자식 클래스의 인스턴스에 대해 다양하게 재정의(override)할 수 있었고, 인스턴스가 어떤 타입으로 형 변환되어도 그 특징이 유지되는 것을 볼 수 있다.

이를 다형성의 한 사례로 메서드 오버라이드(method override) 라고 한다.

- Move라는 이름의 메서드를 정의했다고 해서 자식 클래스에서 그것과 동일한 이름을 사용하기 위해 반드시 virtual/override를 붙여야 할 절대적인 이유는 없다. 때로는 자식 클래스에서 다형성 차원에서가 아닌 순수하게 독립적인 하나의 메서드로 이름을 정의하고 싶은 경우도 고려해야 한다.

- C#에서는 같은 이름의 메서드를 일부러 겹쳐서 정의했다는 개발자의 의도를 명시적으로 표현할 수 있게 new 예약어를 제공한다.

class Lion : Mammal
{
    new public void Move(){}	//구현 생략
}
class Whale : Mammal
{
    new public void Move(){}	//구현 생략
}
class Human : Mammal
{
    new public void Move(){}	//구현 생략
}

부모와 자식 클래스에서 동일한 이름의 메서드를 사용하려면 두 가지 중 하나를 선택해야 한다.

  1. 메서드 오버라이드를 원할 때 - virtual/override를 사용한다.
  2. 단순히 자식 클래스에서 동일한 이름의 메서드가 필요할 때 - new 예약어를 사용한다.

base를 이용한 메서드 재활용

public class Computer
{
    virtual public void Boot()
    {
        Console.WriteLine("메인보드 켜기");
    }
}
public class Notebook : Computer
{
    override public void Boot()
    {
        Console.WriteLine("메인보드 켜기");
        Console.WriteLine("액정 화면 켜기");
    }
}

위 코드에서는 '메인보드 켜기'라는 동작이 부모와 자식에 중복되어 있다. → '중복 코드 제거' 원칙에 위배되는 사례

이런 경우 base 키워드를 사용하면 간단하게 문제가 해결된다.

public class Computer
{
    virtual public void Boot()
    {
        Console.WriteLine("메인보드 켜기");
    }
}
public class Notebook : Computer
{
    override public void Boot()
    {
        base.Boot();
        Console.WriteLine("액정 화면 켜기");
    }
}

메서드 오버라이드를 사용할 때 한 가지 주의사항

위의 코드는 base를 이용해 부모 클래스에서 제공되는 기능을 사용하는 반면, Mammal/Lion의 관계에서는 base.Move 메서드를 호출하지 않는다.

이처럼 상황에 따라 부모 클래스의 원본 메서드 호출이 필요한지 여부에 따라 달라질 수 있는데, 문제는 부모 클래스를 만들었던 개발자가 자식 클래스에서 base를 호출하거나 호출하지 못하게 강제할 수 있는 방법이 없다는 점이다.

대개 부모 클래스의 기능을 완전히 재정의하고 싶다면 base 메서드 호출을 누락시키고, base 메서드의 기능을 확장하려는 경우 base 메서드 호출과 함께 추가 코드를 작성하는 것이 일반적이다.

object 기본 메서드 확장

public class Object
{
    public virtual bool Equals(object obj);
    public virtual int GetHashCode();
    public virtual string ToString();
    //...생략...
}

일반적으로 ToString의 경우 클래스의 인스턴스 값을 적절하게 표현하는 내용으로 정의한는 것이 보통이다.

ex) 좌표를 나타내는 Point 클래스를 정의했을 때 ToString에서 X, Y 좌푯값을 출력하는것은?

public class Point
{
    int x, y;
    
    public Point(int x, int y)
    {
        this.x = x;
        this.y = y;
    }
    public override string ToString()
    {
        return "X: " + x + ", Y: " + y;
    }
}

이렇게 하면 로그를 남기거나 통합 개발 환경에서 디버깅할 때 ToString에서 반환된 결과가 유용하게 쓰일 수 있다.

Point pt = new Point(5, 10);
Console.WriteLine(pt.ToString());
//출력 결과 x: 5, y: 10

Equals와 GetHashCode의 실제 사용 예

책을 고유하게 나타내기 위해 모든 속성의 값을 비교할 수도 있지만, 반드시 그럴 필요는 없다.

책의 속성 중에는 그것을 고유하게 식별할 수 있는 값이 이미 존재할 수도 있기 때문이다

- 특정 객체를 고유하게 식별할 수 있는ㄴ 값을 키(key)라고 한다.

//Book 타입의 Equals 개선
class Book
{
    decimal isbn13;
    string title;
    string contents;
    
    public Book(decimal isbn13, string title, string contents)
    {
        this.isbn13 = isbn13
        this.title = title;
        this.contents = contents;
    }
    public override bool Equals(object obj)
    {
        Book book = obj as Book;
        if(book == null)
        {
            return false;
        }
        
        return this.isbn13 == book.isbn13;
    }
}

isbn13 필드의 값을 비교해 책을 구분하도록 새롭게 정의된 Book 타입은 우리가 원래 의도했던 결과를 반환

Book book1 = new Book(9788998139018, "리버스 엔지니어링 바이블", "......");
Book book2 = new Book(9788998139018, "리버스 엔지니어링 바이블", "......");
Book book3 = new Book(9788992939409, "파이썬 3.6 프로그래밍", "......");

Console.WriteLine("book1 == book2: " + book1.Equals(book2));
Console.WriteLine("book1 == book3: " + book1.Equals(book3));
//출력 결과
//book1 == book2: True
//book1 == book3: False

Book 타입의 GetHashCode는 어떻게 정의하는가?

비교 대상이 isbn13 필드 값이기 때문에 '같은 객체'가 같은 해시 코드'를 반환하기 위해 isbn13 필드의 해시 코드를 반환하는 것으로 쉽게 해결이 가능하다.

class Book
{
    //...생략...
    public override int GetHashCode()
    {
        return this.isbn13.GetHashCode();
    }
}

해당 객체의 키(key)가 될 요소를 적절하게 찾는다면 Equals와 GetHashCode는 자연스럽게 만들어질 수 있다.

오버로드

메서드 시그니처(method signature)라는 것이 있다.

일상 생활에서는 서명을 보고 그 주체가 누구인지를 판단할 수 있다. 마찬가지로 메서드 시그니처는 어떤 메서드를 고유하게 규정할 수 있는 정보를 의미한다.

구체적으로 과연 그것이 무엇인지 메서드 정의를 분리해보면 '이름', '반환타입', '매개변수의 수' 등으로 나뉘는데, 바로 이 메서드의 '서명'이 된다. 따라서 '메서드가 같다'라는 말은 '메서드의 시그니처가 동일하다'라는 말로 해석할 수 있다.

 

'오버라이드'는 시그니처가 완전히 동일한 메서드를 재정의할 때 사용하는 것인 반면, 오버로드(overload)는 시그니처 중에서 '반환값'은 무시하고 '이름'만 같은 메서드가 '매개변수의 수', '개별 매개변수 타입'만 다르게 재정의 되는 경우를 말한다.

결국 오버라이드와 오버로드는 모두 '재정의'라는 한 단어로 번역된다. 

오버로드는 크게 '메서드 오버로드'와 '연산자 오버로드'로 나뉜다.

메서드 오버로드

생성자는 반환값이 없는 특수한 메서드로서, '매개변수의 수', '개별 매개변수 타입'만 다른 여러 가지 생성자를 정의함으로써 오버로드라고 불린다.

오버로드가 가능하지 않다면 절댓값을 반환하는 기능을 구현하기 위해 유사한 메서드를 여러 개 정의해야 한다.

class Mathmatics
{
    public int AbsInt(int value)
    {
        return (value >= 0) ? value : ~value;
    }
    public double AbsDouble(double value)
    {
        return (value >= 0) ? value : ~value;
    }
    public decimal AbsDecimal(decimal value)
    {
        return (value >= 0) ? value : ~value;
    }
}

C# 언어에서는 오버로드를 지원하기 때문에 동일한 이름의 메서드로 일관성 있게 클래스 작성이 가능하다.

class MathMatics
{
    public int Abs(int value)
    {
        return (value >= 0) ? value : ~value;
    }
    public double Abs(double value)
    {
        return (value >= 0) ? value : ~value;
    }
    public decimal Abs(decimal value)
    {
        return (value >= 0) ? value : ~value;
    }
}

MathMatics 클래스를 사용하는 입장에서도 Abs라는 메서드 하나로 기억하는 편이 타입별로 나눠진 메서드를 기억하는 것보다 쉽다.

Mathmatics math = new Mathmatics();
Console.WriteLine(math.Abs(-5));	//출력 결과 5
Console.WriteLine(math.Abs(-10.052));	//출력결과 10.052
Console.WriteLine(math.Abs(-20.01));	//출력결과 20.01

Abs 메서드가 반환하는 각각 int, double, decimal 타입의 값이 Console.WriteLine 메서드로 반환되어 출력된다.

Console.WriteLine도 다양한 타입의 값을 받을 수 있게 정의된 메서드 ㅇ오버로드*method overload)의 한 예시이다.

연산자 오버로드

Mammal 타입의 Move 메서드가 자식 클래스에서 동작 방식이 달라졌다는 것을알 수 있다.

메서드만 이렇게 의미가 달라지는 것은 아니다. 연산자 역시 타입별로 재정의할 수 있다.

int n1 = 5;
int n2 = 10;
int sum = n1 + n2;	//sum 값은 15

string txt1 = "123";
string txt2 = "456";
Console.WriteLine(txt1 + txt2);	//출력 결과 123456

이 코드에서는 정수형 타입과 문자열 타입에 각가 더하기 연산을 수행하는데, 타입에 따라 더하기 연산자의 역할이 달라진다.

정수형 타입에서는 정수 연산에 걸맞게 숫자값을 더하는 반면, 문자열 타입에서는 말 그대로 순수하게 문자열을 이어 붙이는 역할을 한다.

 

string 타입이 더하기 연산자를 재정의한 것처럼 우리가 만드는 어떠한 타입도 재정의할 수 있다.

연산자 오버로드(operator overload) 없이 더하기 연산을 해야 한다면 메서드를 이용해 각 기능을 구현해야 한다.

public class Kilogram
{
    double mass;
    
    public Kilogram(double value)
    {
        this.mass = value;
    }
    public Kilogram(Kilogram target)
    {
        return new Kilogram(this.mass + target.mass);
    }
    public override string ToString()
    {
        return mass + "Kg";
    }
}

//kilogram 타입 사용 예제
Kilogram kg1 = new Kilogram(5);
Kilogram kg2 = new Kilogram(10);

Kilogram kg3 = kg1.Add(kg2);

Console.WriteLine(kg3);	//출력 결과 15kg

Add메서드에 대해 연산자 오버로드를 이용하면 + 연산자에 의미를 부여할 수 있다.

public static 타입 operator 연산자(타입 1 변수명1, 타입2 변수명2)
{
    //타입을 반환하는 코드
}

이 문법에 맞게 Kilogram의 + 연산자를 재정의하면 된다.

public class Kilgram
{
    //생략
    public static Kilogram operator +(Kilogram op1, Kilogram op2)
    {
        return new Kilogram(op1.mass + op2.mass);
    }
}

원래의 Add 메서드와 새롭게 + 연산자가 재정의된 메서드의 비교

  1. 메서드 유형이 정적으로 변경되었다.
  2. operator 예약어와 함께 + 연산자 기호가 메서드 이름을 대신한다.

이 정도 변화로 'kg3 = kg1 + kg2' 같은 좀 더 직관적인 표현을 사용할 수 있게 되었다는 점이 연산자 오버로드가 갖는 최대의 장점

 

C#에서는 연산자와 메서드 간의 구분이 없다. 원하는 연산자가 있다면 각 타입의 의미에 맞는 연산으로 재정의하면 된다.

유의사항

C#에서 제공되는 모든 연산자가 재정의 가능한 유형에 포함되는 것은 아니다.

C# 연산자 오버로드 가증 여부
+, -, !, ~, ++, --, true, false 단항 연산자는 모두 오버로드 가능(+, -는 부호 연산자)
+, -, *, /, %, |, ^, <<, >> 이항 연산자는 모두 오버로드 가능(+, -는 사칙 연산자)
==, !=, <, >, <=, >= 비교 연산자는 모두 오버로드가 가능하지만 반드시 쌍으로 재정의해야 한다.
== 연산자를 오버로드 했다면 != 연산자도 해야 한다.
&&, || 논리 연산자는 오버로드 불가능
[] 배열 인덱스 연산자 자체인 대괄호는 오버로드 할 수 없지만 C#에서는 이를 대체하는 별도의 인덱서 구문을 지원한다.
(Type)x 형 변환 연산자 자체인 괄호는 오버로드할 수 없지만 대신 explicit, implicit를 이용한 대체 정의가 가능하다.
+=, -=, *=, /=, %=, &=, !=, ^=, <<=, >>= 복합 대입 연산자 자체는 오버로드할 수 없지만 대입이 아닌 +, -, *, /등의 연산자를 오버로드하면 복합 대입 연산 구문이 지원된다.
기타 연산자 오버로드 할 수 없다.

연산자에 따른 오버로드 가능 여부

- 연산자에서 단항(unary)이란 피연산자(operand)가 하나라는 의미이고, 이항(binary)이란 피연산자가 두 개라는 의미이다.

- ex) a = -2;라는 코드에서 음수를 나타내는 부호 연산자(-)는 우측에 숫자를 하나만 필요로 한다. 반면 a = 2 - 1;이라는 코드에서 - 는 연산자의 좌우에 각각 하나씩 피연산자를 둬야 한다.

오버로드 할 수 있는 다른 연산자는 + 연산자와 정의하는 방법이 유사하다.

클래스 간의 형 변환

타입을 정의하는 것은 '단위(unit)'을 빈번하게 사용하는 프로그램에서도 유용하다.

decimal won = 30000;
decimal dollar = won * 1200;
decimal yen = won * 13;

yen = dollar;	//실수로 이렇게 대입해도 컴파일 오류가 발생하지 않는다.

개발자는 decimal이라는 공통된 타입을 사용해 모든 통화를 표현하고 있는데, 이처럼 암시적으로 정한 규칙은 코드를 유지보수하는 동안 중대한 버그를 발생시킬 수 있는 위험성을 품고 있다.

이런 경우 각 통화를 다음과 같은 타입으로 정의한다.

public class Currency
{
    decimal money;
    public decimal Money{ get { return money;} }
    public Currency(decimal money)
    {
        this.money = money;
    }
}
public class Won : Currency
{
    public Won(decimal money) : base(money){ }
    public override string ToString()
    {
        return Money + "Won";
    }
}
public class Dollar : Currency
{
    public Dollar(decimal money) : base(money){ }
    public override string ToString()
    {
        return Money + "Dollar";
    }
}
public class Yen : Currency
{
    public Yen(decimal money) : base(money) { }
    public override string ToString()
    {
        return Money + "Yen";
    }
}

그러면 부주의하게 통화를 섞어쓰는 위험이 극적으로 줄어든다.

Won won = new Won(1000)
Dollar dollar = new Dollar(1);
Yen yen = new Yen(13);

Won = Yen;	//Yen과 Won의 타입이 다르기 때문에 컴파일 시 오류가 발생한다.

때로는 Won과 Yen사이에 형 변환이 가능하길 바랄 수 있다. 

이렇게 하려면 =(대입 연산자)를 정의해야 하지만 C#에서는 허용하지 않는다. 

대체 구문인 explicit, implicit 메서드를 정의하는 것으로 동일한 목적을 달성할 수 있다.

public class Yen : currency
{
    //생략
    static public implicit operator Won(Yen yen)
    {
        return new Won(yen.Money * 13m);	//1엔당 13원으로 가정
    }
}

implicit operator를 오버로드했으므로 암시적 형 변환을 할 수 있고, 암시적인 형 변환이 가능하므로 명시적으로 캐스팅 연산자를 쓰는 것도 허용된다.

Yen yen = new Yen(100);

Won won1 = yen;	//암시적(implicit) 형 변환 가능
Won won2 = (Won)yen;	//명시적(explicit) 형 변환 가능

Console.WriteLine(won1);	//출력 결과: 1300Won

민감한 통화 단위에 대해 암시적 형 변환을 허용하는 것은 좋지 않다고 판단하는 개발자도 있을 것이다. 

반드시 개발자가 의도한 형 변환만 가능하도록 제한을 걸고 싶다면 이를 위해 implicit 대신 explicit 연산자가 제공된다.

public class Dollar : Currency
{
    //생략
    static public explicit operator Won(Dollar dollar)
    {
        return new Won(dollar.Money * 1000m);
    }
}

Dollar 타입은 explicit만 구현했으므로 반드시 형 변환 연산자를 사용해야 Won 타입으로 변경할 수 있다.

Dollar dollar = new Dollar(1);

Won won1 = dollar;	//암시적(implicit)형 변환 불가능( 컴파일 오류 발생 )
Won won2 = (Won)dollar;	//명시적 (explicit)형 변환 가능

Console.WriteLine(won2);	//출력 결과: 1000Won

Reference

시작하세요! C# 12 프로그래밍 기본 문법부터 실전 예제까지