4.4 다형성과 4.5 C# 클래스 확장 배우기.

 

4.4 다형성 (Polymorphism)

객체지향의 4대 특징: 추상화, 캡슐화, 상속, 다형성

 

4.4.1 메서드 오버라이드

예제 4.17 부모 클래스에서 정의한 메서드와 동일한 이름의 메서드를 자식 클래스에서 정의

Mammal m1 = new();
Lion lion = new();

lion.Move();
class Mammal
{
     public void Move()
    {
        Console.WriteLine("이동한다.");
    }
}
class Lion : Mammal
{
     public void  Move()
    {
        Console.WriteLine("네 발자국으로 이동한다.");
    }
}
Lion lion = new();
Mammal one = lion; //부모 타입으로 형 변환

one.Move();

부모 타입으로 암시적 형 변환시 원래의 lion의 Move 메서드가 아닌 부모 클래스의 Move 메서드가 호출되게 된다.

이런 문제를 해결하고자 가상 메서드(virtual method)를 사용한다. 일반 메서드를 가상 메서드로 바꾸려면 virtual 예약어를 부모클래스 단계에서 명시하면 된다.

 

Mammal m1 = new();
Lion lion = new();
m1 = lion; // 부모 타입으로 형 변환 -> 업캐스팅
m1.Move();  //출력: 네 발자국으로 이동한다.

class Mammal
{
    virtual public void Move()
    {
        Console.WriteLine("이동한다.");
    }
}
class Lion : Mammal
{
    override public void  Move()
    {
        Console.WriteLine("네 발자국으로 이동한다.");
    }
}

부모 클래스의 메서드에는 virtual를 적용하고 자식 클래스의 메서드에는 override를 적용한다면 의도한대로 동작된다.

Mammel asd = new Dog();  // 업캐스팅
asd.Move(); // virtual + override 다형성으로 자식의 Move() 호출
class Mammel
{
    public Mammel()
    {
        Move();
    }
    virtual public void Move()
    {
        Console.WriteLine("Move");
    }
}

class Dog : Mammel
{
    public override void Move()
    {
        Console.WriteLine("WAR");
    }
}

#출력
WAR
WAR

다형성 (메서드 오버라이드 (virtual + override)) 때매 부모에서 Move()호출 시 자식의 Move()를 호출하게 됨.

 

 

예제 4.17에서 정의한 Move메서드는 부모와 자식클래스에서 이름만 같지 동작은 다르게 별도로 이루어졌다.

그러나 virtual/override 예약어 적용을 통해 부모에서 정의한 Move라는 하나의 동작에 대해 자식 클래스의 인스턴스에 따라 다양하게 재정의(override)할 수 있었다.

인스턴스가 어떤 타입으로 형 변환돼도 그 특징이 유지되었다. 이를 메서드 오버라이드(method override)라 한다.

 

예제 4.17

컴파일 경고가 뜬 이유:  부모 클래스에서 Move라는 이름의 메서드를 정의했다 하여 자식 클래스에서 그것과 동일한 이름을 사용하기 위해 반드시 virtual/override를 붙일필요는 없다. 

때론 자식 클래스에서 다형성 차원에서가 아닌 순수하게 독립적인 하나의 메서드로 이름을 정의하고 싶은 경우도 고려해야한다.

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

using System.Security.Cryptography.X509Certificates;

Mammal m1 = new();
Lion lion = new();

lion.Move();
class Mammal
{
    public void Move()
    {
        Console.WriteLine("이동한다.");
    }
}
class Lion : Mammal
{
    new public void Move()
    {
        Console.WriteLine("네 발자국으로 이동한다.");
    }
}

 

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

1. 메서드 오버라이드를 원하는 가? 그러면 virtual/override를 사용해라

2. 단순히 자식클래스에서 동일한 이름의 메서드가 필요했던 것인가? 그러면 new를 사용하라.

 

 

4.4.1.1 base를 이용한 메서드 재사용

 

 

public class Computer
{
    virtual public void Boot()
    {
        Console.WriteLine("메인보드 켜기");
    }
}

public class Notebook : Computer
{
    public override void Boot()
    {
        base.Boot();  //중복 코드 제거 목적 Console.WriteLine("메인보드 켜기");
        Console.WriteLine("액정 화면 켜기");
    }
}

 

메서드 오버라이드를 사용할 때 주의사항이 있다. 위 코드는 base를 이용해 부모 클래스에서 제공되는 기능을 사용한다.

이에 반해 이전의 코드에서 Mammal/Lion 관계에선 base.Move 메서드를 호출하지 않았다. 상황에 따라 부모 클래스의 원본 메서드 호출 필요여부가 달라질 수 있다. 문제는 부모 클래스를 개발한 개발자가 자식 클래스에서 base를 호출하거나 호출하지 못하게 강제할 수 있는 방법이 없다. 대개 부모 클래스의 기능을 완전히 재정의 하고자 한다면 base 메서드 호출을 누락시키고, base 메서드의 기능 확장을 하려는 경우 base메서드 호출과 함께 추가 코드를 작성하는 것이 일반적이다.

 

4.4.1.2 object 기본 메서드 확장

public class Object
{
    public virtual bool Equals(object obj);
    public virtual int GetHashCode();
    public virtual string ToString();

    //...[ 생략 ]...
}

 

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

 

예제4.18 ToString을 재정의한 Point

Point pt = new Point(5, 10);
Console.WriteLine(pt.ToString());

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;  
    }
}

#출력 결과
X: 5, Y: 10

 

 

예제 4.19 Book 타입의 Equals 개선

Book book1 = new Book(123, "aaa", "내용1");
Book book2 = new Book(123, "aaa", "내용1");
Book book3 = new Book(345, "abb", "내용2");

Console.WriteLine("book1 == book2 : " + book1.Equals(book2));
Console.WriteLine("book1 == book3 : " + book1.Equals(book3));
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;
    }
}

 

GetHashCode 추가

Book book1 = new Book(123, "aaa", "내용1");
Book book2 = new Book(123, "aaa", "내용1");
Book book3 = new Book(345, "abb", "내용2");

Console.WriteLine("book1 == book2 : " + book1.Equals(book2));
Console.WriteLine("book1 == book3 : " + book1.Equals(book3));
Console.WriteLine("book1 GetHashCode: " + book1.GetHashCode());
Console.WriteLine("book2 GetHashCode: " + book2.GetHashCode());
Console.WriteLine("book3 GetHashCode: " + book3.GetHashCode());
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;
    }


    public override int GetHashCode()
    {
        return this.isbn13.GetHashCode();
    }
}

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

 

정리글: 해당 객체의 키(Key)가 될 요소를 적절히 잘 찾는다면 Equals와 GetHashCode는 자연스럽게 만들어질 수 있다.

 

 

4.4.2 오버로드

메서드가 같다 == 메서드의 시그니처가 동일하다

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

오버라이드는 시그니처가 완전히 동일한 메서드를 재정의 할 때 사용한다.

오버로드는 시그니처 중 반환값은 무시하고 이름만 같은 메서드가 매개변수의 수 , 개별 매개변수 타입만 다르게 재정의 되는 경우이다.

 

오버로드 : 메서드 오버로드 , 연산자 오버로드

 

4.4.2.1 메서드 오버로드

생성자는 반환값이 없는 특수한 메서드이며, 이것도 오버로드 사용이 가능하다.

 

4.4.2.2 연산자 오버로드

연산자는 타입별로 재정의  할 수 있다.

int n1 = 5;
int n2 = 10;
int sum = n1 + n2;

string txt1 = "123";
string txt2 = "456";
Console.WriteLine(txt1 + txt2);

 

위 코드에서 정수형 타입과 문자열 타입에 대해 각각 더하기 연산을 수행하는데, 타입에 따라 더하기 연산자의 역할이 달라진 다는 것을 알 수 있다. 정수형 타입의 경우 숫자값을 더하지만 문자열 타입은 문자열을 이어 붙어준다.

 

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

 

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

Kilogram kg1 = new Kilogram(5);
Kilogram kg2 = new Kilogram(10);
Kilogram kg3 = kg1.Add(kg2);
Console.WriteLine(kg3);
public class Kilogram
{
    double mass;

    public Kilogram(double value)
    {
        this.mass = value;
    }
    public Kilogram Add(Kilogram target)
    {
        return new Kilogram(this.mass + target.mass);
    }

    public override string ToString()
    {
        return mass + "kg";
    }
}

# 출력결과: 15kg

 Add 메서드에 대해 연산자 오버로드를 이용하면 + 연산자에 의미를 부여할 수 있는데, 그 방법이 일반 메서드 정의와 별반 다르지 않다.

 

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

위 문법에 맞게 Kilogram 의 +연산자를 재정의 해보자.

 

Kilogram kg1 = new Kilogram(5);
Kilogram kg2 = new Kilogram(10);
//Kilogram kg3 = kg1.Add(kg2);

Kilogram kg3 = kg1 + kg2;   #연산자는 operator 오버로드에 표시된 연산자로 설정. 결정은 return에 사용된 연산자
Console.WriteLine(kg3);
public class Kilogram
{
    double mass;

    public Kilogram(double value)
    {
        this.mass = value;
    }
    //public Kilogram Add(Kilogram target)
    //{
    //    return new Kilogram(this.mass + target.mass);
    //}
    public static Kilogram operator +(Kilogram op1, Kilogram op2)  #연산자는 operator 오버로드에 표시된 연산자로 설정
    {
        return new Kilogram(op1.mass + op2.mass); #결정은 return에 사용된 연산자
    }

    public override string ToString()
    {
        return mass + "kg";
    }
}


출력: 15kg

 

연산자 오버로드를 사용하기 위한 변경점 1) 메서드 유형이 정적으로 변경, 2) operator 예약어와 함께 + 연산자 기호가 메서드 이름을 대신함.

이를 통해 kg3 = kg1 + kg2 같은 직관적인 연산 표현이 가능하다.

 

C#에서는 연산자와 메서드간의 구분이 없다. 원하는 연산자가 있다면 각 타입의 의미에 맞는 연산으로 새롭게 재정의하면 된다(하지만, 현실에선 이렇게 사용하지는 않음.)

 

 

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

 

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

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

 

더보기

1. 단항 연산자(Unary Operator) 오버로딩

단항 연산자는 피연산자가 하나인 연산자야.

(1) +, -, !, ~, ++, -- 연산자 오버로딩

  • + : 그대로 반환하는 경우 많음
  • - : 부호 반전
  • ! : 논리 반전
  • ~ : 비트 반전
  • ++, -- : 증가 및 감소

예제: +, -, ! 연산자 오버로딩

using System;

class Number
{
    public int Value { get; }

    public Number(int value)
    {
        Value = value;
    }

    // + 연산자 (그대로 반환)
    public static Number operator +(Number n) => new Number(+n.Value);

    // - 연산자 (부호 반전)
    public static Number operator -(Number n) => new Number(-n.Value);

    // ! 연산자 (0이면 true, 0이 아니면 false)
    public static bool operator !(Number n) => n.Value == 0;

    public override string ToString() => Value.ToString();
}

class Program
{
    static void Main()
    {
        Number n1 = new Number(10);
        Number n2 = -n1; // 부호 반전
        bool isZero = !n1; // 논리 반전

        Console.WriteLine(n1);  // 출력: 10
        Console.WriteLine(n2);  // 출력: -10
        Console.WriteLine(isZero); // 출력: False
    }
}

 

(2) ++ 및 -- 연산자 오버로딩

증감 연산자는 전위(prefix)와 후위(postfix) 연산자를 구분해야 해.

class Counter
{
    public int Value { get; private set; }

    public Counter(int value) { Value = value; }

    // 전위 증가 (++x)
    public static Counter operator ++(Counter c)
    {
        return new Counter(c.Value + 1);
    }

    // 전위 감소 (--x)
    public static Counter operator --(Counter c)
    {
        return new Counter(c.Value - 1);
    }

    public override string ToString() => Value.ToString();
}

class Program
{
    static void Main()
    {
        Counter c = new Counter(5);
        Console.WriteLine(++c); // 출력: 6
        Console.WriteLine(--c); // 출력: 5
    }
}

후위(x++, x--) 연산자는 따로 정의할 수 없음.
(C++과 달리 C#에서는 후위 연산자를 오버로딩할 수 없음.)

 

2. 이항 연산자(Binary Operator) 오버로딩

이항 연산자는 두 개의 피연산자를 가지는 연산자야.

(1) +, -, *, /, % 연산자 오버로딩

class Vector
{
    public int X { get; }
    public int Y { get; }

    public Vector(int x, int y)
    {
        X = x;
        Y = y;
    }

    // + 연산자 (벡터 합)
    public static Vector operator +(Vector v1, Vector v2)
    {
        return new Vector(v1.X + v2.X, v1.Y + v2.Y);
    }

    // - 연산자 (벡터 차)
    public static Vector operator -(Vector v1, Vector v2)
    {
        return new Vector(v1.X - v2.X, v1.Y - v2.Y);
    }

    public override string ToString() => $"({X}, {Y})";
}

class Program
{
    static void Main()
    {
        Vector v1 = new Vector(3, 4);
        Vector v2 = new Vector(1, 2);
        Vector sum = v1 + v2; // 벡터 덧셈

        Console.WriteLine(sum); // 출력: (4, 6)
    }
}

 

(2) * 연산자 오버로딩 (벡터와 스칼라 곱)

class Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    // * 연산자 (스칼라 곱)
    public static Point operator *(Point p, int scalar)
    {
        return new Point(p.X * scalar, p.Y * scalar);
    }

    public override string ToString() => $"({X}, {Y})";
}

class Program
{
    static void Main()
    {
        Point p = new Point(4, 5);
        Point result = p * 3; // 연산자 오버로딩 사용

        Console.WriteLine(result); // 출력: (12, 15)
    }
}

 

3. 비교 연산자(Comparison Operator) 오버로딩

비교 연산자는 두 객체를 비교할 때 사용해.
==와 !=는 반드시 함께 정의해야 함
<와 >를 정의하면 <=, >=도 함께 정의하는 것이 일반적

(1) == 및 != 연산자 오버로딩

class Person
{
    public int Age { get; }

    public Person(int age)
    {
        Age = age;
    }

    // == 연산자
    public static bool operator ==(Person p1, Person p2)
    {
        if (ReferenceEquals(p1, p2)) return true;
        if (p1 is null || p2 is null) return false;
        return p1.Age == p2.Age;
    }

    // != 연산자
    public static bool operator !=(Person p1, Person p2)
    {
        return !(p1 == p2);
    }

    public override bool Equals(object obj) => obj is Person p && this == p;
    public override int GetHashCode() => Age.GetHashCode();
}

class Program
{
    static void Main()
    {
        Person p1 = new Person(25);
        Person p2 = new Person(25);
        Person p3 = new Person(30);

        Console.WriteLine(p1 == p2); // True
        Console.WriteLine(p1 != p3); // True
    }
}

 

(2) <, >, <=, >= 연산자 오버로딩

class Box
{
    public int Volume { get; }

    public Box(int volume)
    {
        Volume = volume;
    }

    public static bool operator <(Box b1, Box b2) => b1.Volume < b2.Volume;
    public static bool operator >(Box b1, Box b2) => b1.Volume > b2.Volume;
    public static bool operator <=(Box b1, Box b2) => b1.Volume <= b2.Volume;
    public static bool operator >=(Box b1, Box b2) => b1.Volume >= b2.Volume;
}

class Program
{
    static void Main()
    {
        Box b1 = new Box(10);
        Box b2 = new Box(20);

        Console.WriteLine(b1 < b2);  // True
        Console.WriteLine(b1 > b2);  // False
    }
}

 

 

**단항 연산자: a= -2 처럼 코드에서 부호 연산자(-)가 우측 숫자 하나만 필요로 하는 연산자.

**이항 연산자: a = 2 - 1 .처럼 - 연산자의 좌우 각 하나씩 피연산자 2, 1를 두는 연산자

 

 

4.4.2.3 클래스 간의 형 변환

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

yen = dollar; // 실수로 이러한 대입 시 컴파일 오류가 발생하지 않아서 중대한 버그 발생

 

위와 같이 버그를 쉽게 유발할 수 있는 코드 즉 코드에서 냄새가 나는 부분을 해소해보자.(code smells)

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

won = yen; // yen과 won 타입이 다르기에 컴파일 에러 발생
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과 Yen사이 형변환이 가능하길 바랄 수 있다.(원->엔으로 바꾸는 등 환전 할 경우)

 

Yen yen = new Yen(100);

Won won1 = yen; //암시적(implicit)형 변환 가능
Won won2 = (Won)yen;
Console.WriteLine(won1); // 1300Won
Console.WriteLine(won2);// 1300Won
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";
    }

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

 

그러나 암시적 형변환 허용을 하지 않는 것이 좋다고 판단하는 개발자가 있을 수 있으며, 이들을 위해 개발자가 의도한 형 변환만 가능하도록 제한을 걸 고자 implicit 대신 explicit 연산자가 제공된다.

Dollar dollar = new Dollar(1);

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

Console.WriteLine(won2);

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";
    }
    static public explicit operator Won(Dollar dollar)
    {
        return new Won(dollar.Money * 1000m); // 1엔당 13원으로 가정
    }
}

public class Yen : Currency
{
    public Yen(decimal money) : base(money) { }
    public override string ToString()
    {
        return Money + "Yen";
    }

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

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

 

4.5 C#의 클래스 확장

4.5.1 타입 유형 확장

4.5.1.1 중첩 클래스

중첩 클래스(nested class): 클래스 내부에 또 다른 클래스를 정의한 클래스.

public class HardDisk
{
    class Platter
    {

    }
    class Head
    {

    }

    Platter[] platter;
    Head head;
}

 

앞서 접근 제한자를 설명할 떄 class의 경우 접근 제한자를 생략 시 default로 internal이 지정되지만 줍첩 클래스의 경우 private이 default로 지정된다. 이에 따라 외부에서 직접 인스턴스 생성이 불가능해진다.

HardDisk.Head head = new HardDisk.Head(); // 접근제한, 컴파일 에러

 

외부에서 사용 시 public 접근 제한자로 지정해야함.

 

중첩 클래스를 사용하는 이유

코드 조직화: 논리적으로 관련된 클래스를 그룹화할 수 있음.
캡슐화 강화: 외부 클래스의 세부 구현을 숨길 수 있음.
가독성 향상: 관련된 클래스가 함께 있어 유지보수가 용이함.
외부 클래스와 긴밀한 연관성: 내부 클래스가 외부 클래스의 private 멤버에 접근 가능.

 

언제 중첩 클래스를 사용할까?

📌 외부 클래스와 강한 관계를 가지는 경우.
📌 외부 클래스의 구현을 도와주는 유틸리티 클래스로 사용할 때.
📌 외부에서 직접 접근할 필요 없이 내부적으로만 사용될 때.
📌 클래스가 특정 클래스에 종속적이라면 굳이 전역 클래스로 만들 필요 없음.

 

 

4.5.1.2 추상 클래스

앞서 설명한 override 개념과 달리 부모클래스의 인스턴스를 생성하지 못하게 하면서 특정 메서드에 대해 자식들이 반드시 재정의하도록 강제할 수 있다. 이를 위해 추상 클래스(abstract class)와 추상 메서드(abstract method)를 사용한다. 

 

**Override: 메서드 오버라이드는 virtual 메서드를 정의한 부모 클래스에서 그에 대한 기본적인 기능을 구현하고, 자식 클래스에서는 override 예약어를 이용해 그 기능을 재정의한다. 또한 부모 클래스와 자식 클래스 모두 new를 이용해 인스턴스를 생성하는 것이 가능하다

 

추상 메서드: abstract 예약어가 지정되고 구현 코드가 없는 메서드. 추상 메서드는 일반 클래스에 존재할 수 없으며, 추상클래스 안에서만 선언 할 수 있다. 

abstract 예약어가 지정된 추상 메서드를 '코드 없는 가상 메서드(virtual method)'라고 이해하자.

** 추상 메서드에는 접근 제한자로 private을 지정할 수 없는 것이 반드시 자식 클래스에서 재정의 해야하기 때문이다.

 

추상 클래스는  abstract 예약어가 지정돼 있다는 점을 제외하면 일반 클래스 정의와 완전 동일하다.

abstract 예약어를 사용함에 따라 일반클래스와 달리, 1) new를 사용해 인스턴스 생성 불가한 것과 2) 추상 메서드를 가질 수 있다.

 

**추상 클래스에는 반드시 추상 메서드가 포함돼야 하는 것은 아니지만, new로 인스턴스화 할 수 없다.

 

DrawingObject line = new Line(new Point(10, 10), new Point(20, 20));
line.Draw(); // 다형성에 따라 Line.Draw 호출
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;
    }
}

abstract class DrawingObject // 추상 클래스
{
    public abstract void Draw(); // 추상 메서드(코드 없는 가상 메서드)
    public void Move() => Console.WriteLine("Move");
}

class Line : DrawingObject
{
    Point pt1, pt2;

    public Line(Point pt1, Point pt2)
    {
        this.pt1 = pt1;
        this.pt2 = pt2;
    }
    public override void Draw() // 추상 클래스의 추상 메서드를 반드시 정의.
    {
        Console.WriteLine("Line " + pt1.ToString() + " ~ " + pt2.ToString());
    }
}

추상 메서드는 가상 메서드에 속하기 떄문에 자식 클래스에서 override 예약어를 사용해 재정의 한다.

 

추상메서드는 가상 메서드이므로 다형성의 특징이 그대로 적용된다.

상기 코드는 그려지지 않는 도형이란 아무런 의미 없다고 판단한 것과 같으며 그려져야 하는 도형의 부모 클래스에서 Draw 동작을 미리 정의해 두는 것도 가능하지 않기 때문에 추상 클래스와 추상 메서드를 조합한 것이다.

 

코드가 비어있는 가상 메서드와 일반 클래스의 조합으로 정의해도 무방하나 가상 메서드는 자식 클래스에서 재정의 하지 않아도 컴파일 할 때 오류가 발생하지 않지만 추상 클래스의 추상 메서드는 자식 클래스에서 반드시 재정의해야만 컴파일 된다.

컴파일 단계에서 재정의를 강제하고 싶을 때 추상 클래스와 추상 메서드를 사용할 수 있다.

 

4.5.1.3 델리게이트

관례적으로 델리게이트 타입의 이름은 끝에 Delegate 접미사 붙임.

 

예제4.20 델리게이트 사용

public class Mathmatics
{
    delegate int CalcDelegate(int x, int y);

    static int Add(int x, int y) => x + y;
    static int Subtract(int x, int y) => x - y;
    static int Multiply(int x, int y) => x * y;
    static int Divide(int x, int y) => x / y;

    CalcDelegate[] methods;

    public Mathmatics()
    {
        //static 메서드를 가리키는 델리게이트 배열 초기화
        methods = new CalcDelegate[] { Mathmatics.Add, Mathmatics.Subtract, Mathmatics.Multiply, Mathmatics.Divide };
    }

    //methods 배열에 담긴 델리게이트를 opCode 인자에 따라 호출
    public void Calculate(char opCode, int operand1, int operand2)
    {
        switch(opCode)
        {
            case '+':
                Console.WriteLine("+: " + methods[0](operand1, operand2));
                break;
            case '-':
                Console.WriteLine("-: " + methods[1](operand1, operand2));
                break;
            case '*':
                Console.WriteLine("*: " + methods[2](operand1, operand2));
                break;
            case '/':
                Console.WriteLine("/: " + methods[3](operand1, operand2));
                break;
        }
    }
}

class Program
{
    //3개의 매개변수를 받고 void를 반환하는 델리게이트 정의
    //매개변수의 타입이 중요할 뿐 매개변수의 이름은 임의로 정할 수 있다.
    delegate void WorkDelegate(char arg1, int arg2, int arg3);

    static void Main(string[] args)
    {
        Mathmatics math = new();
        WorkDelegate work = math.Calculate;
        work('+', 10, 5);
        work('-', 10, 5);
        work('*', 10, 5);
        work('/', 10, 5);
    }
}

 

출력결과
+: 15
-: 5
*: 50
/: 2

 

델리게이트는 타입이다.

변수 사용시 델리게이트도 함께 사용된다.

*델리게이트 특징

1. 메서드의 반환값으로 델리게이트를 사용할 수 있음.

2. 메서드의 인자로 델리게이트를 전달할 수 있음.

3. 클래스의 멤버로 델리게이트를 정의할 수 있음.

 

델리게이트는 곧 cpp에서의 함수포인터로 메서드를 가리킨다.

1. 메서드의 반환값으로 메서드를 사용 할 수 있다.

2, 메서드의 인자로 메서드를 전달할 수 있다.

3. 클래스의 멤버로 메서드를 정의 할 수 있다.

 

델리게이트의 실체: 타입 (p215, 189)

delegate 예약어가 메서드를 가리킬 수 있는 내부 닷넷타입(MulticastDelegate)에 대한 간편 표기법이다.

상속: System.Object -> System.Delegate -> System.MulticastDelegate 

Mathmatics math = new();
WorkDelegate func = new WorkDelegate(math, math.Calculate);
func.Invoke('+', 10, 5);
class WorkDelegate : System.MulticastDelegate
{
    public WorkDelegate(object obj, IntPtr method);
    public virtual void Invoke(char arg1, int arg2, int arg3);
}

 

만일 delegate 예약어 없이 WorkDelegate를 정의한다면 직접 위와 같이 코드를 작성해야한다. (C#은 MulticastDelegate를 직접 상속해서 정의하는 구문을 허용하지 않으며 위 코드는 단순 참고 코드이다.)

 

#여러개의 메서드를 가리키는 Delegate

namespace ConsoleApp1;

class Program
{
    delegate void CalcDelegate(int x, int y);

    static void Add(int x, int y) { Console.WriteLine(x + y); }
    static void Subtract(int x, int y) { Console.WriteLine(x - y); }
    static void Multiply(int x, int y) { Console.WriteLine(x * y); }
    static void Divide(int x, int y) { Console.WriteLine(x / y); }


    static void Main(string[] args)
    {
        CalcDelegate calc = Add;
        calc += Subtract;
        calc += Multiply;
        calc += Divide;

        calc(10, 5);
    }
}

# 출력 결과
15
5
50
2

 

위 코드를  C# 컴파일러가 빌드시 아래와 같이 개별 델리게이트 생성 및 이들을 결합하는 코드로 자동으로 바꿔준다.

using System;

class Program
{
    // 델리게이트 선언
    delegate int CalcDelegate(int a, int b);

    // 메서드 정의
    static int Add(int x, int y) => x + y;
    static int Subtract(int x, int y) => x - y;
    static int Multiply(int x, int y) => x * y;
    static int Divide(int x, int y) => y != 0 ? x / y : 0; // 0으로 나누기 방지

    static void Main()
    {
        // 개별 델리게이트 생성
        CalcDelegate calc = new CalcDelegate(Add);
        CalcDelegate subtractCalc = new CalcDelegate(Subtract);
        CalcDelegate multiplyCalc = new CalcDelegate(Multiply);
        CalcDelegate divideCalc = new CalcDelegate(Divide);

        // 델리게이트를 결합
        calc = CalcDelegate.Combine(calc, subtractCalc) as CalcDelegate;
        calc = CalcDelegate.Combine(calc, multiplyCalc) as CalcDelegate;
        calc = CalcDelegate.Combine(calc, divideCalc) as CalcDelegate;

        // 연결된 델리게이트 확인
        Console.WriteLine("🔹 등록된 델리게이트 목록:");
        foreach (var t in calc.GetInvocationList())
        {
            Console.WriteLine($"- {t.Method.Name}");
        }

        // 실행 및 결과 출력
        Console.WriteLine("\n🔹 실행 결과:");
        foreach (var t in calc.GetInvocationList())
        {
            var method = (CalcDelegate)t; // 개별 델리게이트로 캐스팅
            Console.WriteLine($"{method.Method.Name}(10, 5) = {method(10, 5)}");
        }
    }
}

 

 

+= 와 반대로 -=연산자는 MulticastDelegate의 메서드 보관 목록에서 해당 메서드를 제거하는 역할이다.

 

C# 9.0에서 델리게이트보다 성능이 향상된 함수 포인터 구문 제공됨(17.14 절 함수포인터)

 

콜백 메서드

메서드 입장에서의 호출자(caller)와 피호출자(callee)

콜백이란 피호출자에서 호출자의 메서드를 호출하는 것을 의미하며 이때 역으로 호출된 '호출자 측의 메서드'를 콜백 메서드라 한다.

class Source
{
    public int GetResult() => 10; 

    public void Test()
    {
        Target target = new();
        target.Do(this); // 1번 호출
    }
}
class Target
{
    public void Do(Source obj)
    {
        Console.WriteLine(obj.GetResult());// 2번 호출
    }
}

[중요!] 콜백 메서드 원리.

1번 호출에서는 Source 타입에서의 호출자이고 Target 타입이 피호출자가 된다.

피호출자가 정의한 Do메서드 내부에서 다시 호출자의 타입에 정의된 메서드를 호출하고 있다. 이러한 2번 호출을 콜백이라 하고 Source 타입의 GetResult 멤버가 콜백 메서드가 된다.

 

예제 4.21 델리게이트를 사용한 콜백

int[] intArray = new int[] { 5, 2, 3, 1, 0, 4 };

SortObject so = new(intArray);
so.Sort();
so.Display();
class SortObject //배열을 정렬할 수 있는 기능을 가진 타입 정의
{
    int[] numbers;

    public SortObject(int[] numbers) //배열을 생성자의 인자로 받아서 보관
    {
        this.numbers = numbers;
    }

    public void Sort()
    {
        int temp;
        for(int y = 0; y < numbers.Length; y++)
        {
            int lowPos = y;

            for(int x = y + 1; x < numbers.Length; ++x)
            {
                if (numbers[x] < numbers[lowPos])
                {
                    lowPos = x;
                }
            }
            temp = numbers[lowPos];
            numbers[lowPos] = numbers[y];
            numbers[y] = temp;
        }
    }

    public void Display()
    {
        for(int idx = 0; idx < numbers.Length; idx++)
        {
            Console.Write(numbers[idx] + ", ");
        }
        Console.WriteLine();
    }
}

출력: 0, 1, 2, 3, 4, 5,

 

SortObject에서 오름차순과 내림차순을 각각 구현하려면 Sort메서드를 2개 만드는 것이 바람직하지 않다. 왜? 비교하는 코드가 하나의 줄이기 때문이다. 이에 bool ascending 메개변수를 추가해 오름차순과 내림차순을 선택하면 좋다.

 

그 외 비교하는 코드를 외부에서 선택하도록 델리게이트로 만드는 것도 가능하다.

public delegate bool CompareDelegate(int arg1, int arg2);

public void Sort(CompareDelegate compareMethod)
{
    // 생략
    if (compareMethod(numbers[x], numbers[lowPos]))
    {
        lowPos = x;
    }
    // 생략
}

Sort 메서드 코드는 간결하고 오름차순, 내림차순을 외부에서 원하는대로 정하는 것이가능해졌다.

 

 

using System;

delegate bool CompareDelegate(int arg1, int arg2);

class SortObject
{
    private int[] _array;

    public SortObject(int[] array)
    {
        _array = array;
    }

    public void Sort(CompareDelegate compare)
    {
        for (int i = 0; i < _array.Length - 1; i++)
        {
            for (int j = 0; j < _array.Length - 1 - i; j++)
            {
                if (!compare(_array[j], _array[j + 1]))
                {
                    // Swap
                    int temp = _array[j];
                    _array[j] = _array[j + 1];
                    _array[j + 1] = temp;
                }
            }
        }
    }

    public void Display()
    {
        foreach (var item in _array)
        {
            Console.Write(item + " ");
        }
        Console.WriteLine();
    }
}

class Program
{
    static void Main(string[] args)
    {
        int[] intArray = new int[] { 5, 2, 3, 1, 0, 4 };

        SortObject so = new SortObject(intArray);
        so.Sort(AscendingCompare); // 오름차순 정렬
        so.Display();

        Console.WriteLine();

        so.Sort(DescendingCompare); // 내림차순 정렬
        so.Display();
    }

    public static bool AscendingCompare(int arg1, int arg2)
    {
        return (arg1 < arg2);
    }

    public static bool DescendingCompare(int arg1, int arg2)
    {
        return (arg1 > arg2);
    }
}


##Delegate 사용한 버블정렬
출력:

0,1,2,3,4,5

5,4,3,2,1,0

 

 

 

 

using System;

class Program
{
    static void Main()
    {
        Person[] persons = new Person[]
        {
            new Person(25, "Alice"),
            new Person(30, "Bob"),
            new Person(22, "Charlie")
        };

        SortPerson sp = new SortPerson(persons);

        sp.Sort();
        sp.Display();
    }
}

class Person
{
    public int Age;
    public string Name;

    public Person(int age, string name)
    {
        Age = age;
        Name = name;
    }

    public override string ToString() => Name + ": " + Age;
}

class SortPerson
{
    Person[] men;
    public SortPerson(Person[] men)
    {
        this.men = men;
    }

    public void Sort()
    {
        Person temp;

        for (int y = 0; y < men.Length; ++y)
        {
            int lowPos = y;
            for (int x = y + 1; x < men.Length; ++x)
            {
                if (men[x].Age < men[lowPos].Age)
                {
                    lowPos = x;
                }
            }
            temp = men[lowPos];
            men[lowPos] = men[y];
            men[y] = temp;
        }
    }

    public void Display()
    {
        for (int idx = 0; idx < men.Length; ++idx)
        {
            Console.WriteLine(men[idx] + ", ");
        }
    }
}

 

위 코드는 Delegate를 사용하지 않고  Age필드에 대해 내림차순 기능을 추가한것이다. 만일 요구사항을 추가하여 Person 타입의 Name필드에 대해서도 오름차순/내림차순 증 다중조건 우선순위 정렬을 지원 시 if문과 더불어 코드가 점점 복잡해진다.

이러한 다중조건에 따른 코드 복잡성 문제를 델리게이트를 사용함으로써 해결 가능하다.

using System;

class Program
{
    static bool AscSortByAgeAndName(Person arg1, Person arg2)
    {
        //string 객체의 CompareTo 메서드는 문자열 비교를 수행
        //문자열이 사전 정렬 순으로 비교해서 크면 1, 같으면 0, 작으면 -1 반환
        // 0보다 작은 값 반환 시 true로 가정하면 오름차순 정렬.
        if (arg1.Age.CompareTo(arg2.Age) < 0) return true;
        return arg1.Name.CompareTo(arg2.Name) < 0;
    }
    static void Main()
    {
        Person[] persons = new Person[]
        {
            new Person(25, "Alice"),
            new Person(30, "Bob"),
            new Person(30, "Dine"),
            new Person(22, "Charlie")
        };

        SortPerson sp = new SortPerson(persons);
        sp.Sort(AscSortByAgeAndName);
        sp.Display();
    }
}

class Person
{
    public int Age;
    public string Name;

    public Person(int age, string name)
    {
        Age = age;
        Name = name;
    }

    public override string ToString() => Name + ": " + Age;
}

delegate bool CompareDelegate(Person arg1, Person arg2);
class SortPerson
{
    Person[] men;
    public SortPerson(Person[] men)
    {
        this.men = men;
    }

    public void Sort(CompareDelegate compareMethod)
    {
        Person temp;

        for (int y = 0; y < men.Length; ++y)
        {
            int lowPos = y;
            for (int x = y + 1; x < men.Length; ++x)
            {
                if (compareMethod(men[x], men[lowPos]))
                {
                    lowPos = x;
                }
            }
            temp = men[lowPos];
            men[lowPos] = men[y];
            men[y] = temp;
        }
    }

    public void Display()
    {
        for (int idx = 0; idx < men.Length; ++idx)
        {
            Console.WriteLine(men[idx] + ", ");
        }
    }
}

 다중 조건을 통한 정렬(with delegate)

 

int 타입과 Person 타입을 정렬하기 위해 SortObject, SortPerson으로 나눠 구현했다.

상기 코드들을 보면 정렬을 위한 코드는 대부분 변경되지 않고 타입에 의존적인 코드만 바꾸었다. 

 

이제부터 모든 타입의 부모인 object를 사용하면 2개의 분리된 클래스를 하나로 합쳐보자.

 

예제 4.23 델리게이트와 object를 이용한 범용 정렬 코드

delegate bool CompareDelegate(object arg1, object arg2); // object 인자 2개

class SortObject
{
    object[] things;

    public SortObject(object[] things)
    {
        this.things = things;
    }

    public void Sort(CompareDelegate compareMethod)
    {
        object temp;

        for(int y = 0; y < things.Length; ++y)
        {
            int lowPos = y;
            for(int x = 0; x< things.Length; ++x)
            {
                if (compareMethod(things[x], things[lowPos]))
                {
                    lowPos = x;
                }
            }

            temp = things[lowPos];
            things[lowPos] = things[y];
            things[y] = temp;
        }
    }
    public void Display()
    {
        for(int idx = 0; idx < things.Length; ++idx)
        {
            Console.WriteLine(things[idx].ToString());
        }
    }

}
class Person
{
    public int age;
    public string name;

    public Person(int age, string name)
    {
        this.age = age;
        this.name = name;
    }
    public override string ToString()
    {
        return name + ": " + age;
    }
}
class Program
{
    static bool AscSortByName(object arg1, object arg2)
    {
        Person person1 = arg1 as Person;
        Person person2 = arg2 as Person;
        if (person1.age.CompareTo(person2.age) < 0)
        {
            return true;
        }
        return person1.name.CompareTo(person2.name) < 0;
    }

    static void Main(string[] args)
    {
        Person[] personArray = new Person[]
        {
            new Person(25, "a"),
            new Person(215, "b"),
            new Person(25, "c"),
        };
        SortObject so = new SortObject(personArray);
        so.Sort(AscSortByName);
        so.Display();
    }
}

 

object를 사용해 Sort메서드를 타입에 종속적이지 않게 만들고 객체를 비교하는 코드를 외부에서 지정 가능하도록 델리게이트를 사용했다. 이를 통해 int 및 Person 타입에 제한되지 않고 모든 타입에 대해 SortObject 클래스를 이용해 정렬을 수행 할 수 있다.

class Program
{
    static bool AscSortByName(object arg1, object arg2)
    {
        Person person1 = arg1 as Person;
        Person person2 = arg2 as Person;
        if (person1.age.CompareTo(person2.age) < 0)
        {
            return true;
        }
        return person1.name.CompareTo(person2.name) < 0;
    }

    static void Main(string[] args)
    {
        
        int[] arr = new int[]
        {
            1,2,3,4,
        };
        SortObject so = new SortObject(arr);
        so.Sort(AscSortByName);
        so.Display();
    }
}

위 코드는 다시 보자. int[]arr 를 인자로 넣을 시 컴파일에러발생.

현재 SortObject 클래스의 생성자는 object[]를 받도록 되어 있지만 int[] 배열을 전달하면 문제가 발생한다. int[]는 object[]와 호환되지 않기 때문이다.

 

호환되는 string[] 배열을 보자

delegate bool CompareDelegate(object arg1, object arg2); // object 인자 2개

class SortObject
{
    object[] things;

    public SortObject(object[] things)
    {
        this.things = things;
    }

    public void Sort(CompareDelegate compareMethod)
    {
        object temp;

        for(int y = 0; y < things.Length; ++y)
        {
            int lowPos = y;
            for(int x = y + 1; x< things.Length; ++x)
            {
                if (compareMethod(things[x], things[lowPos]))
                {
                    lowPos = x;
                }
            }

            temp = things[lowPos];
            things[lowPos] = things[y];
            things[y] = temp;
        }
    }
    public void Display()
    {
        for(int idx = 0; idx < things.Length; ++idx)
        {
            Console.WriteLine(things[idx].ToString());
        }
    }

}
class Person
{
    public int age;
    public string name;

    public Person(int age, string name)
    {
        this.age = age;
        this.name = name;
    }
    public override string ToString()
    {
        return name + ": " + age;
    }
}
class Program
{
    static bool AscSortByName(object arg1, object arg2)
    {
        if((arg1 is string) && (arg2 is string))
        {
            var str1 = arg1 as string;
            var str2 = arg2 as string;
            return str1.CompareTo(str2) < 0;
        }
        Person person1 = arg1 as Person;
        Person person2 = arg2 as Person;
        if (person1.age.CompareTo(person2.age) < 0)
        {
            return true;
        }
        return person1.name.CompareTo(person2.name) < 0;
    }

    static void Main(string[] args)
    {
        Person[] personArray = new Person[]
        {
            new Person(25, "a"),
            new Person(215, "b"),
            new Person(25, "c"),
        };
        string[] arr = new string[]
        {
            "aa","bb","abc","za"
        };
        SortObject so = new SortObject(arr);
        so.Sort(AscSortByName);
        so.Display();
    }
}

object를 사용함으로써 object와 호환되는 타입들 어느것이나 와도 타입 제약이 없다.

즉, 델리게이트만 전달하는 것으로  코드 재사용 능력을 극대화 할 수 있다.

 

4.5.1.4 인터페이스

접근_제한자 interface 인터페이스_명
{
  // 메서드 선언;
}

*인터페이스에는 메서드 선언을 0개 이상 포함할 수 있다. 관례적으로 인터페이스 이름 앞에는 I접두사를 붙인다.

 

추상클래스 VS 인터페이스

추상 클래스는 클래스로 다중 상속이 불가하다. 인터페이스는 클래스가 아니기에 다중 상속이 허용된다.

abstract class DrawingObject
{
    public abstract void DrawingObjectDraw();
    public abstract void DrawingObjectMove(int offset);
}
abstract class DrawingObject2
{
    public abstract void DrawingObjectDraw2();
    public abstract void DrawingObjectMove2(int offset);
}
interface IDrawingObject
{
    void IDrawingObjectDraw();
    void IDrawingObjectMove(int offset);
}
interface IDrawingObject2
{
    void IDrawingObject2Draw();
    void IDrawingObject2Move(int offset);
}
class Paint : DrawingObject, DrawingObject2, IDrawingObject, IDrawingObject2
{
    public override void DrawingObjectDraw()
    {
        throw new NotImplementedException();
    }

    public override void DrawingObjectMove(int offset)
    {
        throw new NotImplementedException();
    }

    public void IDrawingObject2Draw()
    {
        throw new NotImplementedException();
    }

    public void IDrawingObject2Move(int offset)
    {
        throw new NotImplementedException();
    }

    public void IDrawingObjectDraw()
    {
        throw new NotImplementedException();
    }

    public void IDrawingObjectMove(int offset)
    {
        throw new NotImplementedException();
    }
}

상기 코드는 클래스 2개(DrawingObject, DrawingObject2) 를 상속시켜서 컴파일에러가 발생된다. (클래스는 하나만 상속가능)

 

 

인터페이스는  구현없이 interface test{}  {}안에 내용이 비어있어도된다. 또한, 추상 메서드와는 달리 override 예약어가 필요없다.

 

인터페이스의 메서드를 자식 클래스에서 구현 할 때는 반드시 public 접근 제한자를 명시해야한다. 아니면 다음과 같이 인터페이스명을 직접 붙이는 경우 public 접근 제한자를 생략해도된다.

class Paint : DrawingObject, IDrawingObject, IDrawingObject2
{
    ...
    void IDrawingObject2.IDrawingObject2Draw()
    {
        throw new NotImplementedException();
    }

    void IDrawingObject2.IDrawingObject2Move(int offset)
    {
        throw new NotImplementedException();
    }
    ...
}

 

주의할 부분은 인터페이스는 public이 없다고하여 private이 되는 건 아니다.

 

Paint p = new();
p.IDrawingObjectDraw(); // 암시적 형변환이됨
//p.IDrawingObject2Draw(); //암시적 형변환안되서 명시적으로 형변환해야함
IDrawingObject2 obj = p as IDrawingObject2;
obj.IDrawingObject2Draw();

abstract class DrawingObject
{
    public abstract void DrawingObjectDraw();
    public abstract void DrawingObjectMove(int offset);
}
abstract class DrawingObject2
{
    public abstract void DrawingObjectDraw2();
    public abstract void DrawingObjectMove2(int offset);
}
interface IDrawingObject
{
    void IDrawingObjectDraw();
    void IDrawingObjectMove(int offset);
}
interface IDrawingObject2
{
    void IDrawingObject2Draw();
    void IDrawingObject2Move(int offset);
}
class Paint : DrawingObject, IDrawingObject, IDrawingObject2
{
    
    void IDrawingObject2.IDrawingObject2Draw()
    {
        throw new NotImplementedException();
    }

    void IDrawingObject2.IDrawingObject2Move(int offset)
    {
        return;
    }

    public void IDrawingObjectDraw()
    {
        return;
    }

    public void IDrawingObjectMove(int offset)
    {
        return;
    }
    public override void DrawingObjectDraw()
    {
        return;
    }

    public override void DrawingObjectMove(int offset)
    {
        return;
    }

}
#중요

Paint p = new();
p.IDrawingObjectDraw(); # 암시적 형변환이됨

p.IDrawingObject2Draw(); #컴파일에러 발생: 암시적 형변환안되서 명시적으로 형변환해야함

IDrawingObject2 obj = p as IDrawingObject2; 
obj.IDrawingObject2Draw();  # 반드시 IDrawingObject2 인터페이스로 형변환해서 호출

 

인터페이스는 메서드의 묶음이다. C# Property가 내부적으로는 메서드로 구현되기때문에 인터페이스에는 Property 역시 포함할 수 있다.

  

interface IMonitor
{
    void TrunOn();
    int Inch { get; set; } //Property get/set 포함
    int Width { get; }  //get만 포함하는것도 가능
}

class Notebook : IMonitor
{

    public void TrunOn()
    {
    
    }

    int inch;
    public int Inch
    {
        get { return inch; }
        set { inch = value; }
    }

    int width;
    public int Width { get { return width; } }
}

 

 

상속으로서의 인터페이스

인터페이스는 클래스 상속이아니어서 구현코드를 이어받지는 않지만 적어도 메서드의 묶음에 대한 정의를 이어 받은 것에 해당한다. 서로다른 클래스라도 인터페이스만 공통 구현될 시 해당 구현 클래스의 인스턴스에 대해 인터페이스로 접근 가능하다. 

// 인터페이스 자체는 new로 인스턴스화 할 수 없다. 다만 인터페이스 배열은 가능하다.
IDrawingObject[] instances = { new Line(), new Rectangle() };


//자식 클래스로부터 암시적 형 변환 가능
IDrawingObject instance = new Line();
instance.Draw();

foreach(IDrawingObject item in instances)
{
    item.Draw();  //인터페이스를 상속받은 객체의 Draw 메서드 호출
}
interface IDrawingObject
{
    void Draw();
}

class Line : IDrawingObject
{
    public void Draw() { Console.WriteLine("Line"); }
}

class Rectangle : IDrawingObject
{
    public void Draw() { Console.WriteLine("Rectangle"); }
}


#출력
Line
Line
Rectangle

 

이번 경우는 인터페이스가 없었다 해도 abstract 타입으로 바꿀 수 있다. 위 코드에서는 전혀 영향 받지 않고 잘 실행된다.

// 인터페이스 자체는 new로 인스턴스화 할 수 없다. 다만 인터페이스 배열은 가능하다.
IDrawingObject[] instances = { new Line(), new Rectangle() };


//자식 클래스로부터 암시적 형 변환 가능
IDrawingObject instance = new Line();
instance.Draw();

foreach(IDrawingObject item in instances)
{
    item.Draw();  //인터페이스를 상속받은 객체의 Draw 메서드 호출
}
public abstract class IDrawingObject
{
    public abstract void Draw();
}

class Line : IDrawingObject
{
    public override void Draw() { Console.WriteLine("Line"); }
}

class Rectangle : IDrawingObject
{
    public override void Draw() { Console.WriteLine("Rectangle"); }
}

 

 

인터페이스 자체로 의미 부여

인터페이스에 메서드가 포함돼 있지 않은 상태, 즉 비어있는 인터페이스를 상속받는 것으로도 의미가 부여될 수 있다.

System.Object 클래스의 ToString을 재정의한 클래스만을 구분하고 싶으면 어떻게 할까? 인터페이스가 없다면 별도의 boolean 형 필드를 둬서 개발자가 명시해야한다. 

더보기

이 문제는 System.Object의 ToString() 메서드를 재정의(override)한 클래스만 구분하는 방법을 고민하는 내용입니다.

🔍 문제 이해

  1. C#에서 모든 클래스는 System.Object를 상속받으며, ToString() 메서드를 기본적으로 가짐.
  2. 어떤 클래스는 ToString()을 재정의(override) 하고, 어떤 클래스는 재정의하지 않음.
  3. 인터페이스가 없는 상황에서, ToString()을 재정의한 클래스만 구분하는 방법이 필요.
  4. 이를 위해 "개발자가 직접 boolean 필드를 설정해야 한다." 라는 아이디어가 제시됨.
class BaseClass
{
    public bool IsToStringOverridden = false; // 기본적으로 false
}

class DerivedClass : BaseClass
{
    public DerivedClass()
    {
        IsToStringOverridden = true; // 개발자가 직접 true 설정
    }

    public override string ToString() => "재정의된 ToString()";
}

class Program
{
    static void Main()
    {
        BaseClass obj1 = new BaseClass();
        DerivedClass obj2 = new DerivedClass();

        Console.WriteLine(obj1.IsToStringOverridden);  // False
        Console.WriteLine(obj2.IsToStringOverridden);  // True
    }
}

 

그러나 인터페이스 활용시 다음과 같이 구분이 가능하다.

interface IObjectToString { } // ToString을 재정의한 클래스에만 사용될 빈 인터페이스 정의.

class Computer { } // ToString을 재정의하지 않은 예제 타입

class Person : IObjectToString // ToString을 재정의했다는 의미로 인터페이스 상속
{
    string name;
    public Person(string name)
    {
        this.name = name;
    }

    public override string ToString()
    {
        return "Person: " + this.name;
    }
}

class Program
{
    private static void DisplayObject(object obj)
    {
        if(obj is IObjectToString) // 인터페이스로 형 변환 가능 여부
        {
            Console.WriteLine(obj.ToString());
        }
    }

    static void Main(String[] args)
    {
        DisplayObject(new Computer());
        DisplayObject(new Person("홍길동"));
    }
}

 

인터페이스는 지켜야 할 계약으로 보면된다. 클래스 Person은  IObjectToString 인터페이스가 요구하는 암시적인 계약을 ToString을 재정의함으로써 지켰다. 반면 Computer 클래스는 그 계약을 갖고 있지 않다.

 

인터페이스를 이용한 콜백 구현(p209 / app: 235)

델리게이트를 사용한 콜백 예제 4.21을 인터페이스로 바꾸면 다음과 같다.

Source sr = new();
sr.Test();
interface ISource
{
    int GetResult(); // 콜백용으로 사용될 메서드를 인터페이스로 분리한다.
}

class Source : ISource
{
    public int GetResult()
    {
        return 10;
    }
    public void Test()
    {
        Target target = new();
        target.Do(this);
    }
}

internal class Target
{
    public void Do(ISource obj) // Source 타입이 아닌 ISource 인터페이스를 받는다.
    {
        Console.WriteLine(obj.GetResult()); // 콜백 메서드 호출
    }
}

 

델리게이트 사용보단 상속이란 개념으로 콜백을 구현하는것이 이해하기가 더 쉽다. 

 

**콜백을 구현할 수 있는 중요 수단인 함수포인터에 대해 C#은 델리게이트를 제공하지만 자바는 그러한 개념이 없다. 그러나 인터페이스가 있기때문에 문제될 사안이 아니다.

 

콜벡 구현 시 델리게이트와 인터페이스 중 어느 것을 선택할지의 기준

 델리게이트는 각 메서드마다 델리게이트를 정의해야하며 인터페이스는 하나의 타입에서 여러개의 메서드 계약을 담을 수 있기에 인터페이스를 대체적으로 선호한다. 다만, 델리게이트는 여러 개의 메서드를 담을 수 있어서 한 번의 호출을 통해 다중으로 등록된콜백 메서드를 호출할 수 있는 장점이 있다.

 

다중 호출에 대한 필요성이 없다면 인터페이스를 이용해 콜백을 구현하는것이 더 일반적이다. 

 

Array.Sort는 단순 배열을 오름차순 정렬하지만, 인터페이스 인자를 사용하는 경우 내림차순 정렬도 가능하다. Array.Sort에는 다음과 같이 IComparer 인터페이스를 인자로 받는 메서드가 오버로드가 되어 제공되기 때문이다.

public static void Sort(Array array);
public static void Sort(Array array, IComparer comparer);

 

Array와 마찬가지로 IComparer 인터페이스도 닷넷에정의되어 있어 단순 Compare라는 메서드 유형을 선언하고 있다. 

namespace System.Collections;

public interface IComparer
{
    //x가 y보다 크면 1, 같으면 0, 작다면 -1을 반환하는 것으로 약속된 메서드
    int Compare(object x, object y);
}

 

예제 4.24 IComparer  인터페이스를 이용한 Array.Sort 사용

using System.Collections; // IComparer가 정의된 네임스페이스를 사용

class IntegerCompare : IComparer // IComparer를 상속받는 타입 정의
{
    // IComparer 인터페이스의 Compare 메서드를 구현
    // 이 메서드는 Array.Sort 메서드 내에서 콜백으로 호출 됨

    public int Compare(object x, object y)
    {
        int xValue = (int)x;
        int yValue = (int)y;

        if (xValue > yValue) return -1; // 내림차순 정렬이 되도록 -1 반환
        else if (xValue == yValue) return 0;

        return 1;
    }
}

class Program
{
    static void Main(string[] args)
    {
        int[] intArray = new int[] { 1, 2, 3, 4, 5 };

        //IComparer를 상속받은 IntegerCompare 인스턴스 전달
        Array.Sort(intArray, new IntegerCompare());
        foreach(int item in intArray)
        {
            Console.Write(item + ", ");
        }
    }
}

# 출력: 5, 4, 3, 2, 1

IComparer를 구현한 인스턴스를 함께 인자로 넘기면 Array.Sort는 요소의 값을 비교하기 위해 IComparer.Compare 메서드에 2개의 값을 전달한다. 즉, Compare 메서드는 Array.Sort 메서드가 한번 호출 될 시 내부에서는 요수의 수에 비례해 여러번 걸쳐 호출된다.

 

델리게이트로 구현한 정렬 예제 4.23과 인터페이스로 구현한 정렬 예제 4.24를 비교 시 구현 상 큰 차이가 없다.

 

IEnumerable 인터페이스

#닷넷에 정의되어 있는 IEnumerable 인터페이스
namespace System.Collections;

public interface IEnumerable
{
  IEnumerator GetEnumerator();
}

인터페이스에 정의된 유일한 메서드인 GetEnumerator는 열거자(enumerator)라고 하는 객체를 반환하도록 약속돼 있다. 열거자란 IEnumerator 인터페이스를 구현한 객체를 일컫는데, 다시 IEnumerator 인터페이스의 정의를 살펴보면 다음과 같다.

 

# 닷넷에 정의돼 있는 IEnumerator 인터페이스
namespace System.Collections;

public interface IEnumerator
{
  object Current {get;} // 현재 요소를 반환하도록 약속된 get 프로퍼티
  bool MoveNext(); //다음 순서의 요소로 넘어가도록 메서드
  void Reset();  //열거 순서를 처음으로 되돌릴 때 호출하면 되는 메서드
}

 

예제 4.25 IEnumerable 인터페이스를 구현한 객체의 요소를 열거하는 방법

 

 

using System.Collections;

int[] intArray = new int[] { 1, 2, 3, 4, 5 };

IEnumerator enumerator = intArray.GetEnumerator();
//Console.WriteLine(enumerator.Current);  #런타임에러.
while (enumerator.MoveNext())
{

    Console.Write(enumerator.Current + ", ");
}

#출력  
1, 2, 3, 4, 5,

IEnumerator 형식의 요소 값을 꺼낼때 MoveNext()가 선행되지 않으면 런타임에러가 나온다.(MoveNext 호출로 시작되지 않았다는 에러)

 

foreach문은 위 IEnumerable 인터페이스를 구현하고 있는 객체에 대해 좀 더 쉽게 열람할 수 있는 열거문법이다.

foreach의 in 다음 오는 객체가 IEnumerable 인터페이스 구현 시 어떤 객체든 요소를 열거할 수 있다. 

 

 

foreach(var ch in "KOREA")
  Console.Write( ch +", ");
  
 #출력
 K, O, R, E, A,

 

 

인터페이스의 내부 구현은 다양할 수 있지만, 메서드의 '약속된 작업'만 보장해 준다면, 인터페이스를 사용하는 측에서는 동일한 방식으로 우리가 만드는 객체를 다룰 수 있다.

 

하기 코드 직접 구현해보기

using System.Collections;

Notebook notebook = new();
foreach(var item in notebook)  // in 에서 MoveNext() 로 가네.
{
    Console.WriteLine(item.ToString());
}


class Hardware { }

class USB
{
    string name;
    public USB(string name) { this.name = name; }

    public override string ToString()
    {
        return name;
    }

}

class Notebook : Hardware, IEnumerable
{
    USB[] usbList = new USB[] { new USB("USB1"), new USB("USB2") };
    public IEnumerator GetEnumerator() // IEnumerator를 구현한 열거자 인스턴스 반환.
    {
        return new USBEnumerator(usbList);
    }
    public class USBEnumerator : IEnumerator // 중첩 클래스로 정의된 열거자 타입
    {
        int pos = -1;
        int length = 0;
        object[] list;

        public USBEnumerator(USB[] usb)
        {
            list = usb;
            length = usb.Length;
        }
        public object Current  // 현재 요소를 반환하도록 약속된 접근자 메서드
        {
            get { return list[pos]; }
        }
        public bool MoveNext() // 다음 순서의 요소를 지정하도록 약속된 메서드
        {
            if (pos >= length - 1) return false;
            pos++;
            return true;
        }

        public void Reset() // 처음부터 열거하고 싶을 때 호출하면 되는 메서드
        {
            pos = -1;
        }
    }
}

 

 

느슨한 결합(loose coupling)

강력한 결함(tight couping)은 클래스간 호출 자체가 강력한 결함이다.

class Computer
{
    public void TurnOn()
    {
        Console.WriteLine("Computer: TurnOn");
    }
}

class Switch
{
    public void PowerOn(Computer machine) // Computer 타입을 직접 사용
    {
        machine.TurnOn();
    }
}

Switch에 Monitor를 단다고 하면 Computer 대신 Switch를 대체 할 것이다. 그런데 Switch 코드가 바뀌는 것은 당연하게 여겨지지 않는데 이렇게 어떠한 변화로 수천/수만줄 코드를 변화시킬 수 있는 것이 강력한 결합이다.

이것의 보완책으로 느슨한 결합이 나왔으며 인터페이스를 활용해 느슨한 결합을 구현한다.

 

interface IPower
{
    void TurnOn();
}

class Monitor : IPower
{
    public void TurnOn()
    {
        Console.WriteLine("Monitor: TurnOn");
    }
}

class Switch
{
    public void PowerOn(IPower machine) // 특정 타입 ->인터페이스
    {
        machine.TurnOn();
    }
}

결합에 대한 문제를 간단히 해결했다. 즉 Monitor에서 다시 Computer로 바꾸더라도 내부 코드를 바꿀 필요가 없어진다. Monitor나 Computer 또는 다른 타입을 정의해 PowerOn메서드에 전달한다 해도 IPower 인터페이스를 상속받는다는 약속만 지키면 내부 코드는 전혀 변경할 필요 없다.

느슨한 결합이란 클래스 간에 구현 타입의 정보 없이 인터페이스등의 방법을 이용해 상호간에 맺은 계약만으로 동작하는 것을 의미한다.

 

확장이 가능함. 다른 타입 정의시 확장해서 정의하면된다.

 

4.5.1.5 구조체

구조체는 class와 같은 사용자 정의 형식이지만 참조가아닌 값 형식이다.

 

 

더보기

1. Console.WriteLine의 동작 원리

Console.WriteLine은 다양한 데이터 타입을 출력할 수 있도록 오버로드(overload)된 여러 버전을 가지고 있습니다.

  • Console.WriteLine(object? value);
  • Console.WriteLine(string? value);
  • Console.WriteLine(int value);
  • Console.WriteLine(double value);
  • ...

여기서 v3는 Vector 구조체의 인스턴스이며, Console.WriteLine의 매개변수 타입과 일치하는 오버로드된 메서드가 없으면 object 타입을 받는 메서드가 호출됩니다.

 

public static void WriteLine(object? value);

 

2. object 타입을 받으면 ToString()이 호출됨

모든 C#의 타입은 System.Object를 기본적으로 상속받습니다. 따라서 object 타입을 매개변수로 받는 WriteLine 메서드는 내부적으로 매개변수로 들어온 객체의 ToString()을 호출하여 문자열로 변환한 후 출력합니다.

 

public static void WriteLine(object? value)
{
    if (value != null)
    {
        Console.WriteLine(value.ToString()); // 내부적으로 ToString() 호출
    }
}

즉, Console.WriteLine(v3);를 호출하면, 내부적으로 다음 코드가 실행됩니다.

Console.WriteLine(v3.ToString());

 

3. ToString()을 재정의하지 않으면 기본 동작

만약 ToString()을 재정의하지 않았다면, Vector 구조체는 기본적으로 System.ValueType에서 상속받은 ToString()을 사용하게 됩니다. 이 경우 기본적으로 타입 이름(네임스페이스 포함)이 출력됩니다.

 

struct Vector
{
    public int X;
    public int Y;
}

Vector v3 = new Vector(5, 10);
Console.WriteLine(v3);  // 기본 동작은 "Namespace.Vector" 출력됨

 

하지만 ToString()을 다음과 같이 재정의하면,

public override string ToString()
{
    return "X: " + X + ", Y: " + Y;
}

이제 Console.WriteLine(v3);를 호출할 때 ToString()이 실행되므로 우리가 정의한 문자열이 출력됩니다.


결론

Console.WriteLine(v3);가 ToString()을 호출하는 이유는 오버로드된 WriteLine(object? value) 메서드가 실행되기 때문입니다. 이 메서드는 내부적으로 ToString()을 호출하여 문자열을 얻고 출력합니다.

 

 

c# 주황이에서 박싱언박싱 언급하면서 출력 시 ToString()으로 출력하라고 언급함.

 

struct Vector
{
    public int X;
    public int Y;

    public Vector(int x, int y) // 매개변수를 가진 생성자 정의
    {
        this.X = x; //구조체가 가진 모든 필드를 초기화.
        this.Y = y;
    }

    public override string ToString() //System.Object의 ToString 재정의
    {
        return "X: " + X + ", Y: " + Y;
    }
}

class Program
{
    static void Main(string[] args)
    {
        Vector v1 = new(); // new를 사용해 인스턴스 생성 가능
        Vector v2;  //new 가 없어도 인스턴스 생성 가능

        Vector v3 = new Vector(5, 10); //명시적으로 생성자 지정 가능

        Console.WriteLine(v3);  // ToString으로 이동함. WriteLine 
    }
}

값 형식의 변수를 new로 생성하면 해당 변수의 모든 값을 0으로 할당한 것과 동일하다.

int n1 = new int();

Console.WriteLine(n1);

#출력: 0

구조체 뿐만 아니라 기본형도 동일하게 new로 할당 할 수 있으며 기본값은 0이다.

값형식에 속하는 모든 타입은 기본적으로 메모리 상태가 0으로 초기화된다. 그러면 개발자가 값을 명시적으로 할당하든

할당하지 않든 new로 생성한 인스턴스와 같은 상태일 텐데 굳이 명시적으로 0을 할당해야하는 이유는 ? -> C# 컴파일러는 개발자가 직접 코드상에서 값을 할당하지 않는 변수를 사용하는 것을 오류라고 판단한다.

 

int n1;  // n1은 0의 값을 갖고 있지만 개발자가 할당한 것이 아니다.

Console.WriteLine(n1);  // 컴파일 에러 발생

 

+ Recent posts