본문 바로가기
공부/C#

이것이 C#이다 Chapter 07 클래스

by bokob 2024. 2. 27.

7.1 객체지향 프로그래밍과 클래스

객체지향 프로그래밍(Object Oriented Programming, OOP)은 코드 내 모든 것을 객체(Object)로 표현하려는 프로그래밍 패러다임이다.

여기서 객체는 세상의 모든 것을 지칭한다. 모든 것을 그대로 표현하는 것이 아닌 추상화(객체의 주요 특징만 뽑아낸다)시켜서 표현한다.

객체는 '속성'과 '기능'을 갖는다.

속성은 데이터로 기능은 메소드로 표현한다. 즉, 객체는 데이터와 메소드로 이루어진다.

클래스(Class)는 객체를 만들기 위한 청사진이라고 볼 수 있다. 클래스는 객체가 가지게 될 속성과 기능을 정의하지만 실체를 가지지 않는다. 만들어진 실체(Instance)를 영어 발음 그대로 '인스턴스'라고 부른다.

 

클래스는 복합 데이터 타입

객체지향적인 관점에서 보면 클래스는 객체를 위한 청사진인 동시에 데이터와 메소드를 묶는 집합이다.

코드에서 보는 클래스는 기본 데이터 타입을 조합해서 만드는 복합 데이터 타입이다.

 

7.2 클래스의 선언과 객체의 생성

클래스는 class 키워드를 이용해 선언한다.

class 클래스_이름
{
    // 데이터와 메소드
}

class Dog
{
    public string Name;  // 필드
    public string Color; // 필드
    
    public vooid Woof()  // 메소드
    {
       Console.WriteLine("{0} : 왈", Name);
    }
}

클래스 안에 선언된 변수들을 필드(Field)라고 한다. 그리고 필드와 메소드를 비롯하여 프로퍼티, 이벤트 등 클래스 내에 선언된 요소들을 멤버(Member)라고 한다.

 

Dog backgu = new Dog();
backgu.Color = "흰색";
backgu.Name = "백구";
Console.WriteLine({0} : {1}, backgu.Name, backgu.Color);

 

Dog()은 생성자(Constructor)라고 하는 특별한 메소드이다. 생성자는 클래스의 이름과 같은 동일한 이름을 가지며, 객체를 생성하는 역할을 한다.

new 키워드는 생성자를 호출해서 객체를 생성하는 데 사용하는 연산자이다.

모든 클래스는 복합 데이터 타입이다. 복합 데이터 타입은 참조 타입이다. 

Dog backgu;

 

위의 backgu는 null을 가진다. backgu 자체에 메모리가 할당되는 것이 아니고 backgu는 참조로서 객체가 있는 곳을 가리킬 뿐이기 때문이다. 그래서 new 연산자가 필요한 것이다.

new 연산자와 생성자를 이용해서 힙에 객체를 생성하고, backgu는 생성자가 힙에 생성한 객체를 가리키는 것이다.

 

new 연산자와 생성자는 모든 데이터 타입에 사용할 수 있다

C#에서는 int나 float, string과 같은 데이터 타입도 생성자를 갖고 있다. 아래와 같은 것도 가능은 하다. (귀찮게 할 필요 없음)

int a = new int();
a = 3;
string b = new string(new char [] {'귀','찮'});

 

7.3 객체의 삶과 죽음에 대하여: 생성자와 종료자

객체가 생성될 때는 생성자(Constructor)가 호출되고 소멸할 때는 종료자(Finalizer)가 호출된다.

 

1. 생성자

생성자는 클래스와 이름이 같고 반환 타입이 없다. 생성자는 해당 타입의 객체를 생성하는 것만 하면 되기 때문이다.

class 클래스_이름
{
    한정자 클래스_이름(매개변수_목록) // 생성자
    {
        //
    }
}

 

클래스를 선언할 때 명시적으로 생성자를 구현하지 않아도 컴파일러에서 생성자를 만들어준다. 이를 기본 생성자(Default Consturctor)라고 한다.

기본 생성자가 있는데 생성자를 구현하는 이유는 객체의 상태를, 다시 말해 필드를 원하는 값으로 초기화하고 싶을 때가 있다. 매개변수를 입력받아 원하는 값으로 필드를 초기화하기 위함이다.

생성자도 여느 메소드와 마찬가지로 오버로딩이 가능하다. 다양한 버전의 생성자가 있을 수 있다.

class Dog
{
    public Dog()
    {
       Name = "";
       Color = "";
    }
    
    public Dog(string _Name, string _Color)
    {
        Name = _Name;
        Color = _Color;
    }

    public string Name;
    public string Color;
    
    // ...
}

 

매개변수가 없는 버전의 생성자는 컴파일러가 자동으로 생성해준 생성자를 호출할 때처럼 생성하면 되고, 매개변수가 있는 생성자는 괄호 안에 필요한 인수를 입력하면 된다.

Dog backgu = new Dog();
backgu.Name = "백구";
backgu.Color = "흰색";

Dog baduckyee = new Dog("바둑이", "얼룩색");

 

컴파일러가 기본 생성자를 제공하지 않는 경우

프로그래머가 생성자를 하나라도 직접 정의하면 C# 컴파일러는 매개변수 없는 기본 생성자를 제공하지 않는다.

 

2. 종료자

종료자의 이름은 클래스 이름 앞에 '~'를 붙인 것이다. 종료자는 생성자와 달리 매개변수도 없고, 한정자도 사용하지 않는다. 오버로딩도 불가능학며 직접 호출할 수 없다.

class 클래스_이름
{
    ~클래스_이름()
    {
        //
    }
    
    // 필드
    // 메소드
}

 

종료자는 다음과 같은 이유로 가급적 사용하지 않는 것이 좋다.

사용자는 CLR의 가비지 컬렉터가 언제 동작할지 예측할 수 없다.

또, 종료자를 명시적으로 구현하면 가비지 컬렉터는 클래스의 족보를 타고 올라가 객체로부터 상속받은 Finalize() 메소드를 호출한다. 이러면 응용 프로그램의 성능 저하를 초래할 확률이 높다.

마지막으로 CLR의 가비지 컬렉터는 훨씬 더 똑똑하게 객체의 소멸을 처리할 수 있기 때문이다.

 

7.4 정적 필드와 메소드

'C#에서 static은 메소드나 필드가 클래스의 인스턴스가 아닌 클래스 자체에 소속되도록 지정하는 한정자' 라고 Chpater 06에 정리했었다.

 

정적 필드를 만들어서 얻는 이득이 뭘까? static으로 수식한 필드는 프로그램 전체에 걸쳐 단 하나만 존재한다. 프로그램 전체에 걸쳐 공유해야 하는 변수가 있다면 정적 필드를 이용하면 된다.

 

정적 메소드도 정적 필드처럼 인스턴스가 아닌 클래스 자체에 소속된다.

정적 메소드도 클래스의 인스턴스를 생성하지 않아도 호출이 가능한 메소드다.

 

반대로 비(非) 정적 메소드를 가리키는 용어가 뭘까? 동적 메소드가 아니라 인스턴스에 소속된다 하여 인스턴스 메소드라고 한다. 이름처럼 인스턴스를 생성해야 호출할 수 있는 메소드다.

보통 객체 내부 데이터를 이용해야 하는 경우에는 인스턴스 메소드를 선언하고, 내부 데이터를 이용할 일이 없는 경우에는 별도의 인스턴스 생성 없이 호출할 수 있도록 메소드를 정적으로 선언한다.

 

어떤 경우에 인스턴스 메소드를 선언할지 또는 정적 메소드를 선언할지에 대해서는 MS의 엔지니어들이 .NET 클래스 라이브러리 안에 잘 마련해두었다. 참고하도록 하자.

 

7.5 객체 복사하기: 얕은 복사와 깊은 복사

class MyClass
{
    public int MyField1;
    public int MyField2;
}

 

MyClass source = new MyClass();
source.MyField1 = 10;
source.MyField2 = 20;

MyClass target = source;
target.MyField2 = 30;

Console. WriteLine("{0} {1}", source.MyField1, source.MyField2);
Console. WriteLine("{0} {1}", target.MyField1, target.MyField2);

/* 결과
10 30
10 30
*/

왜 위와 같은 결과가 나올까?

클래스는 참조 타입이기 때문이다. 참조 타입은 메모리의 힙 영역에 객체를 할당하고 메모리의 스택 영역에 있는 참조가 힙 영역에 할당된 메모리를 가리킨다.

즉 처음의 source는 아래와 같은 그림이 된다.

source를 복사해서 받은 target은 힙에 있는 실제 객체가 아닌 스택에 있는 참조를 복사해서 받는다.

밑의 그림처럼 source와 target이 같은 곳을 바라보게 된다. 그래서 target의 MyField2를 30으로 바꿨는데 source의 MyField2도 30으로 바뀌는 문제가 생긴 것이다. 이렇게 객체를 복사할 때 참조만 살짝 복사하는 것을 얕은 복사(Shallow Copy)라고 한다.

얕은 복사

 

원래 의도는 밑의 그림처럼 target이 힙에 보관되어 있는 내용을 source로부터 복사해서 받아 별도의 힙 공간에 객체를 보관하는 것이다. 이를 깊은 복사(Deep Copy) 라고 한다.

C#에서는 깊은 복사를 자동으로 해주는 구문이 없다. 스스로 깊은 복사를 수행하는 코드를 만들어야 한다.

class MyClass
{
    public int MyField1;
    public int MyField2;
    public MyClass DeepCopy()
    {
        MyClass newCopy = new MyClass();
        newCopy.MyField1 = this.MyField1;
        newCopy.MyField2 = this.MyField2;
        return newCopy;
    }
}

 

ICloneable.Clone() 메소드

System 네임스페이스에는 ICloneable 이라는 인터페이스가 있다.

인터페이스란 '클래스가 구현해야 하는 목록'이라 할 수 있다.

'깊은 복사' 기능을 가질 클래스가 .NET의 다른 유틸리티 클래스나 다른 프로그래머 작성한 코드와 호환되도록 하고 싶다면 ICloneable을 상속받는 것이 좋다. ICloneable 인터페이스는 Clone() 메소드 하나만 갖고 있다.

class MyClass : ICloneable
{
    public int MyField1;
    public int MyField2;
    public MyClass Clone() // 객체를 힙에 새로 할당해 그곳에 자신의 멤버를 일일이 복사해 넣는다.
    {
        MyClass newCopy = new MyClass();
        newCopy.MyField1 = this.MyField1;
        newCopy.MyFiedl2 = this.MyField2;
        return newCopy;
    }
}

 

 

7.6 this 키워드

1. 나

this는 객체가 자신을 지칭할 때 사용하는 키워드이다. 객체 외부에서는 객체의 필드나 메소드에 접근할 때 객체의 이름(변수 또는 식별자)을 사용한다면, 객체 내부에서는 자신의 필드나 메소드에 접근할 때 this 키워드를 사용한다는 것이다.

class Employee
{
    private string Name;
    
    public void SetName(string Name)
    {
        this.Name = Name; // 모호성 해결
    }
}

SetName()의 매개변수도 Name이고, Employee 클래스의 필드도 Name이다. this를 이용해 모호성을 해결할 수 있다.

 

2. this() 생성자

class MyClass
{
    int a, b, c;
    
    public MyClass()
    {
        this.a = 5425;
    }
    
    public MyClass(int b)
    {
        this.a = 5425;
        this.b = b;
    }
    
    public MyClass(int b, int c)
    {
        this.a = 5425;
        this.b = b;
        this.c = c;
    }
}

문제가 없지만 생성자 안에 똑같은 코드가 중복되어 들어가 있는 것이 걸린다.

MyClass()는 a를 초기화한다. MyClass(int)는 b만 초기화하고, a를 초기화하지 않는다. 따로 a를 초기화를 위해 생성자를 호출하려고 하지만, new 연산자 없이는 생성자를 호출할 수 없다. 가령 호출해도 또 다른 객체를 만들 뿐이다. 

 

this()는 자기 자신의 생성자를 가리킨다. this()는 생성자에서만 사용할 수 있다. 생성자의 코드 블록 내부가 아닌 앞쪽에서만 가능하다. 밑의 코드는 this() 생성자를 이용해 개선한 코드이다.

 

7.7 접근 한정자로 공개 수준 결정하기

객체지향 프로그래밍에서는 필요한 최소 기능만 노출하고 내부를 감출 것을 요구한다. 이것을 은닉성(Emcapsulation)이라고 한다.

 

객체지향 프로그래밍 3대 특성

객체지향 프로그래밍에는 여러 특성이 있다.

그중에서도 3대 특성으로 꼽히는 것들이 있다. 은닉성, 상속성(Inheritance), 다형성(Polymorphism)이다.

 

클래스에 선언되어 있는 필드와 메소드 중 사용자에게 노출할 것이 있는가 하면, 절대로 노출하지 말아야 하는 것도 있다.

특히 필드는 상수를 제외하고는 '무조건' 감추는 것이 좋다.

 

접근 한정자(Access Modifier)는 감추고 싶은 것은 감추고, 보여주고 싶은 것은 보여줄 수 있도록 코드를 수식하며 필드, 메소드를 비롯해 프로퍼티 등 모든 요소에 대해 사용할 수 있다.

C#에서 제공하는 접근 한정자는 모두 여섯 가지다.

접근 한정자 설명
public 클래스의 내부/외부 모든 곳에서 접근할 수 있다.
protected 클래스의 외부에서는 접근할 수 없지만, 파생 클래스에서는 접근할 수 있다.
private 클래스의 내부에서만 접근할 수 있다. 파생 클래스에서는 접근할 수 없다.
internal 같은 어셈블리에 있는 코드에서만 public으로 접근할 수 있다. 다른 어셈블리에 있는 코드에서는 private과 같은 수준의 접근성을 가진다.
protected internal 같은 어셈블리에 있는 코드에서만 protected로 접근할 수 있다. 다른 어셈블리에 있는 코드에서는 private과 같은 수준의 접근성을 가진다.
private protected 같은 어셈블리에 있는 클래스에서 상속받은 클래스 내부에서만 접근할 수 있다.

 

접근 한정자로 수식하지 않은 클래스의 멤버는 무조건 private으로 접근 수준이 자동 지정된다.

class MyClass
{
    private   int MyField_1;
    protected int MyField_2;
              int MyField_3; // private로 자동 지정
    
    public int MyMethod_1()
    {
        // ...
    }
    
    internal void MyMethod_1()
    {
        // ...
    }
}

 

7.8 상속으로 코드 재활용하기

객체지향 프로그래밍에서는 물려받는 클래스(파생 클래스(Derived Class) 또는 자식 클래스) 라고 한다. 물려주는 클래스(기반 클래스(Base Class) 또는 부모 클래스)라고 한다.

class 기반_클래스
{
    // 멤버 선언
}

class 파생_클래스 : 기반_클래스
{
    // 아무 멤버를 선언하지 않아도 기반 클래스의 모든 것을 물려받아 갖게 된다.
    // 단, private로 선언된 멤버는 예외
}

파생 클래스의 이름 뒤에 콜론(:)을 붙여주고 그 뒤에 상속받을 기반 클래스의 이름을 붙여주면 된다.

class Base
{
    public void BaseMethod()
    {
        Console.WriteLine("BaseMethod");
    }
}

class Derived : Base // Derived 클래스는 Base 클래스를 상속받았으므로 BaseMethod()를 갖는다.
{
}

 

파생 클래스는 자신만의 고유한 멤버 외에도 기반 클래스로부터 물려받은 멤버를 갖고 있다. 파생 클래스가 기반 클래스 위에 얹어 만든 것이기 때문이다.

파생 클래스는 객체를 생성할 때 내부적으로 기반 클래스의 생성자를 호출한 후에 자신의 생성자를 호출하고, 객체가 소멸될 때는 반대의 순서로(파생 클래스 -> 기반 클래스) 종료자를 호출한다.

 

만약 기반 클래스의 생성자가 매개변수를 입력받도록 선언되어 있다면 파생 클래스의 인스턴스를 생성할 때 호출되는 기반 클래스의 생성자에는 어떻게 매개변수를 넘겨줄까?

base 키워드를 이용하면 된다. base는 '기반 클래스'를 가리킨다. base 키워드를 통해 기반 클래스의 멤버에 접근할 수 있다.

class Base
{
    public void BaseMethod()
    {/* ... */}
}

class Derived : Base
{
    public void DerivedMethod()
    {
        base.BaseMethod(); // Base 클래스에 접근할 수 있다.
    }
}

base()는 기반 클래스의 생성자이다. base()에 매개변수를 넘겨 호출하면 Base() 생성자를 통해 초기화할 수 있다.

 

자식이 달라고 하면 부모는 무조건 물려줘야 할까?

아니다. 기반 클래스의 작성자는 의도하지 않은 상속이나 파생 클래스의 구현을 막기 위해 상속이 불가능하도록 클래스를 선언할 수 있다. sealed 한정자를 이용하면 된다. sealed 한정자로 클래스를 수식하면, 이 클래스는 '상속 봉인'되어(이런 클래스를 봉인 클래스라고 한다) 이로부터 상속 받으려는 시도가 컴파일러로부터 발견되었을 때 에러 메시지가 출력된다.

sealed class Base
{
    // ...
}

class Derived : Base // 컴파일 에러
{
    // ...
}

 

7.9 기반 클래스와 파생 클래스 사이의 타입 변환

class Mammal
{
    public void Nurse() {/* ... */}
}

class Dog : Mammal
{
    public void Bark() {/* ... */}
}

class Cat : Mammal
{
    public void Meow() {/* ... */}
}

 

Mammal mammal = new Mammal();
mammal.Nurse();

mammal = new Dog();
mammal.Nurse();

Dog dog = (Dog)mammal;
dog.Nurse();
dog.Bark();

mammal = new Cat();
mammal.Nurse();

Cat cat = (Cat)mammal;
cat.Nurse();
cat.Meow();

기반 클래스와 파생 클래스 사이에서는 타입 변환이 가능하다. 파생 클래스의 인스턴스는 기반 클래스의 인스턴스로도 사용할 수 있다. 이를 잘 이용하면 코드의 생산성이 높아진다.

예를 들면, Mammal 클래스에서 300 개의 파생 클래스가 파생되었다고 하자. Zookeeper 클래스로 동물을 씻기는 Wash() 메소드를 구현한다고 생각해보자. 그러면 300개의 Wash() 메소드를 오버로딩 해야 한다.

class Zookeeper
{
    public void Wash(Dog dog){/*...*/}
    public void Wash(Cat cat){/*...*/}
    public void Wash(Elephant elephant){/*...*/}
    public void Wash(Lion lion){/*...*/}
    //...
}

하지만 300개의 동물 클래스 모두 Mammal 클래스로부터 상속받았기 때문에 이들 모두 Mammal로 간주할 수 있다.

따라서 아래와 같이 하나의 Wash() 메소드만 준비하면 300개의 동물 클래스에 사용할 수 있다.

class Zookeeper
{
    public void Wash(Mammal mammal){/*...*/}
}

 

C#은 타입 변환을 위해 is와 as 연산자를 제공한다.

연산자 설명
is 객체가 해당 타입에 해당하는지 검사하여 그 결과를 bool 값으로 반환한다.
as 타입 변환 연산자와 같은 역할을 한다. 타입 변환 연산자가 변환에 실패하는 경우 예외를 던지는 반면에 as 연산자는 객체 참조를 null로 만든다.

 

Mammal mammal1 = new Dog();
Dog dog;

if(mammal1 is Dog)
{
    dog = (Dog)mammal;
    dog.Bark();
}

Mammal mammal2 = new Cat();

Cat cat = mammal2 as Cat;
if(cat != null) // mammal2가 Cat 타입 변환에 실패했다면 cat은 null이 된다.
{
    cat.Meow();
}

 

일반적으로 as 연산자를 사용하는 것을 권장한다고 'Effective C#' 책에 나온다고 한다. 타입 변환에 실패하더라도 예외가 일어나 갑자기 코드의 실행이 점프되는 일이 없으므로 코드를 관리하기가 더 수월하기 때문이라고 한다.

단, as 연산자는 참조 타입에 대해서만 사용할 수 있으므로 값 타입의 객체는 기존의 타입 변환 연산자를 사용해야 한다.

 

7.10 오버라이딩과 다형성

객체지향 프로그래밍에서 다형성(Polymorphism)은 객체가 여러 형태를 가질 수 있음을 의미한다.

다형성은 원래 하위 형식 다형성(Subtype Polymorphism)의 준말이다. 즉, 자신으로부터 상속받아 만들어진 파생 클래스를 통해 다형성을 실현한다는 것이다.

 

class ArmorSuite
{
    public virtual void Initialize()
    {
        Console.WriteLine("Armored");
    }
}

 

class IronMan : ArmorSuite
{
    // ...
}

class WarMachine : ArmorSuite
{
    // ...
}

ArmorSuite 클래스로부터 물려받은 Initialize() 메소드로는 IronMan과 WarMachine 클래스의 각자의 고유한 필드를 초기화 할 수 없다. 다라서 Initialize()를 오버라이딩(Overriding) 해야한다.

 

메소드를 오버라이딩 하기 위해서는 메소드가 virtual 키워드로 한정되어 있어야 한다.

또, 오버라이딩 측에서는 기반 클래스에 선언되어 있던 Initialize()  메소드를 재정의하고 있음을 컴파일러에 알려야 하므로 override로 한정해줘야 한다.

class ArmorSuite
{
    public virtual void Initialize()
    {
        Console.WriteLine("Armored");
    }
}

class IronMan : ArmorSuite
{
    public override void Initialize()
    {
        base.Initialize();
        Console.WriteLine("Repulsor Rays Armed");
    }
}

class WarMachine : ArmorSuite
{
    public override void Initialize()
    {
        base.Initialize();
        Console.WriteLine("Double-Barrel Cannons Armed");
    }
}

 

private으로 선언한 메소드는 오버라이딩할 수 없다.

private으로 선언된 멤버는 어차피 파생 클래스에서 보이지도 않는다. 같은 이름, 같은 타입, 같은 매개변수를 이용하여 선언했다 하더라도 컴파일러는 기반 클래스의 메소드를 재정의했다고 생각하지 않고 전혀 없었던 메소드를 선언한다고 간주할 것이다.

 

7.11 메소드 숨기기

메소드 숨기기(Method Hiding)란, CLR에게 기반 클래스에서 구현된 버전의 메소드를 감추고 파생 클래스에서 구현된 버전만 보여주는 것을 말한다. 메소드 숨기기는 파생 클래스 버전의 메소드를 new 한정자로 수식함으로써 할 수 있다.(생성자를 호출할 때 사용하는 new 연산자와는 완전히 다른 녀석이다)

class Base
{
     public void MyMethod()
     {
         Console.WirteLine("Base.MyMethod()");
     }
}

class Derived : Base
{
    // Base.MyMethod()를 감추고 Derived 클래스에서 구현된 MyMethod()만 노출한다.
    public new void MyMethod()
    {
        Console.WriteLine("Derived.MyMethod()");
    }
}

Derived 클래스에서 메소드 숨기기를 한 MyMethod()는 다음과 같이 호출할 수 있다.

Derived derived = new Derived();
derived.MyMethod(); // "Derived.MyMethod();" 출력

기반 클래스에서는 아무 생각 없이 메소드를 구현해도 메소드 숨기기를 하면 오버라이딩과 같은 효과를 얻을 수 있다.

메소드 숨기기만 제공하면 될텐데 왜 오버라이딩을 넣었을까? 메소드 숨기기는 오버라이딩과 다르다. 이름 그대로 메소드를 숨기고 있는 것이다. 다음과 같이 객체를 실행하면 CLR에 Base 버전의 MyMethod()가 노출되어 이를 실행한다.

Base baseOrDerived = new Derived();
baseorDerived.MyMethod(); // "Base.MyMethod();" 출력

이처럼 메소드 숨기기는 완전한 다형성을 표현하지 못하는 한계가 있다. 따라서 기반 클래스를 설계할 때는 파생 클래스의 모습까지 고려해야 한다.

 

7.12 오버라이딩 봉인하기

클래스를 상속이 안 되도록 봉인하는 것처럼 메소드도 오버라이딩되지 않도록 sealed 키워드를 이용해서 봉인할 수 있다.

모든 메소드를 봉인할 수 있는 것은 아니고, virtual로 선언된 가상 메소드를 오버라이딩한 버전의 메소드만 가능하다.

class Base
{
    public virtual void SealMe()
    {
        // ...
    }
}

class Derived : Base
{
    public sealed override void SealMe() // 이 메소드만 봉인할 수 있다.
    {
        // ...
    }
}

 

밑의 코드는 컴파일 에러가 난다.

class Base
{
    public virtual void SealMe()
    {
        // ...
    }
}

class Derived : Base
{
    public sealed override void SealMe()
    {
        // ...
    }
}

class WantToOverride : Derived
{
    public override void SealMe()
    {
        
    }
}

class MainApp
{
    static void Main(string[] args)
    {
    }
}

봉인 메소드는 파생 클래스의 작성자를 위한 기반 클래스 작성자의 배려다.

혹시라도 파생 클래스의 작성자가 기반 클래스로부터 상속받은 메소드 하나를 오버라이딩 했는데 이 때문에 클래스의 다른 부분들이 오작동 된다고 생각해봐라. 파생 클래스의 작성자는 자신이 작성한 코드만으로는 객체가 원하는 대로 동작하지 않는 원일을 알 길이 없다.

오작동 위험이 있거나 잘못 오버라이딩함으로써 발생할 수 있는 문제가 예상된다면, 이렇게 봉인 메소드를 이용해서 상속을 사전에 막는 것이 낫다. 컴파일할 때 에러가 나므로 파생 클래스의 작성자는 코드가 제품으로 출시되기 전에 문제를 파악할 수 있다.

 

왜 virtual로 선언된 메소드를 오버라이딩한 버전의 메소드만 봉인 메소드로 만들 수 있을까?

virtual로 선언한다는 건 기반 클래스의 작성자가 해당 메소드를 오버라이딩할 수 있도록 준비해 놓았다는 의미이므로 이 단계에서는 봉인이 의미가 없다. 오버라이딩을 원치 않으면 virtual 한정자를 빼버리면 되기 때문이다.

문제는 오버라이딩한 메소드이다. 오버라이딩한 메소드는 파생 클래스의 파생 클래스에서도 자동으로 오버라이딩이 가능하다. 그래서 이곳에 오버라이딩을 막을 수 있는 sealed 한정자가 필요한 것이다.

 

7.13 읽기 전용 필드

읽기 전용 필드는 이름에서 알 수 있듯이, 읽기만 가능한 필드를 의미한다. 즉, 클래스나 구조체의 멤버로만 존재할 수 있으며 생성자 안에서 한 번 값을 지정하면, 그 후로는 값을 변경할 수 없다.

읽기 전용 필드는 readonly 키워드를 이용해 선언할 수 있다.

 

class Configuration
{
    private readonly int min;
    private readonly int max;
    
    public Configuration(int v1, int v2)
    {
        min = v1;
        max = v2;
    }
}

만약 생성자가 아닌 다른 메소드에서 읽기 전용 필드를 수정하려고 하면 컴파일 에러가 일어난다.

 

7.14 중첩 클래스

중첩 클래스(Nested Class)는 클래스 안에 선언되어 있는 클래스를 말한다.

class OuterClass
{
    class NestedClass // 중첩 클래스
    {
    }
}

클래스 안에 클래스를 선언하면 된다. 객체를 생성하거나 메소드를 호출하는 방법도 보통의 클래스와 같다.

다른점은 자신이 소속된 클래스의 멤버에 자유롭게 접근할 수 있다는 것이다. private 멤버에도 접근할 수 있다.

class OuterClass
{
    private int OuterMember;
    
    class NestedClass
    {
        public void DoSomeThing()
        {
            OuterClass outer = new OuterClass();
            
            // OuterClass의 private 멤버에 접근하여 값을 할당하거나 읽을 수 있다.
            outer.OuterMember = 10;
        }
    }
}

 

중첩 클래스를 사용하는 이유는 기본적을 두 가지 때문이다.

  • 클래스 외부에 공개하고 싶지 않은 타입을 만들고자 할 때
  • 현재 클래스의 일부분처럼 표현할 수 있는 클래스를 만들고자 할 때

다른 클래스의 private 멤버에도 접근할 수 있는 중첩 클래스는 은닉성을 무너뜨리기는 하지만, 프로그래머에게 더욱 유연한 표현력을 가져다준다는 장점이 있다.

 

7.15 분할 클래스

분할 클래스(Partial Class)란, 여러 번에 나눠서 구현하는 클래스를 말한다. 분할 클래스는 그 자체로 특별한 기능을 하는 것은 아니며 클래스의 구현이 길어질 경우 여러 파일에 나눠서 구현할 수 있게 함으로써 소스 코드 관리의 편의를 제공하는데 의미가 있다.

분할 클래스는 partial 키워드를 이용한다.

partial class MyClass
{
    public void Method1() {}
    public void Method2() {}
}

partial class MyClass
{
    public void Method3() {}
    public void Method4() {}
}

MyClass obj = new MyClass();

obj.MyMethod1();
obj.MyMethod2();
obj.MyMethod3();
obj.MyMethod4();

분할 클래스로 구현한 클래스들의 이름은 동일해야 한다.

C# 컴파일러는 분할 구현된 코드를 하나의 클래스로 묶어 컴파일한다.

 

7.16 확장 메소드

확장 메소드(Extendtion Method)는 기존 클래스의 기능을 확장하는 기법이다.

확장 메소드를 이용하면 string 클래스에 문자열을 뒤집는 기능을 넣는 등의 새로운 기능을 넣을 수 있다.

확장 메소드를 선언하는 방법은 다음과 같다.

메소드를 선언하되, static 한정자로 수식해야 한다.

그리고 메소드의 첫 번째 매개변수는 반드시 this 키워드와 함께 확장하려는 클래스(타입)의 인스턴스여야 한다.

그 뒤에 따라오는 매개변수 목록이 실제로 확장 메소드를 호출할 때 입력되는 매개변수다.

메소드는 클래스 없이 선언될 수 없기 때문에 클래스를 하나 선언하고 그 안에 확장 메소드를 선언해야 한다. 이때 선언하는 클래스도 static 한정자로 수식해야 한다.

namespace 네임스페이스_이름
{
    public static class 클래스_이름
    {
        public static 반환_타입 메소드_이름(this 대상_타입 식별자, 매개변수_목록)
        {
            //
        }
    }
}
namespace MyExtention
{
    public static class IntegerExtension
    {
        public int Power(this int MyInt, int exponent)
        {
            int result = myInt;
            for(int i = 1; i < exponent; i++)
                result = result * myInt;
            
            return result;
        }
    }
}
using MyExtention; // 확장 메소드를 담는 클래스의 네임스페이스를 사용

// ...

int a = 2;
Console.WriteLine(a.Power(3)); // Power()가 원래부터 int 타입의 메소드였던 것처럼 사용할 수 있다.
Console.WriteLine(10.Power(4));

 

7.17 구조체

C# 복합 데이터 타입에는 클래스 말고도 구조체(Struct)라는 것이 있다.

구조체는 struct 키워드를 이용해서 선언한다.

struct 구조체_이름
{
    // 필드 ...
    // 메소드 ...
}
struct MyStruct
{
    public int MyField1;
    public int MyField2;
    
    public void MyMethod()
    {
        // ...
    }
}

 

구조체에 왜 public을 사용했을까?

클래스는 private로 했다. 구조체는 public으로 한 이유는 클래스와 존재 이유가 다르기 때문이다. 클래스는 실세계의 객체를 추상화하려는 데 이유가 있지만, 구조체는 데이터를 담기 위한 자료구조로 사용된다. 따라서 굳이 은닉성을 비롯한 객체지향 원칙을 구조체에 강하게 적용하지 않는 편이다. 편의를 위해 필드를 public으로 선언해서 사용하는 경우가 많다.

 

클래스와 구조체의 차이점

특징 클래스 구조체
키워드 class struct
타입 참조 타입(힙에 할당) 값 타입(스택에 할당)
복사 얕은 복사(Shallow Copy) 깊은 복사(Deep Copy)
인스턴스 생성 new 연산자와 생성자 필요 선언만으로도 생성
생성자 매개변수 없는 생성자 선언 가능 매개변수 없는 생성자 선언 불가능
상속 가능 값 타입이므로 상속 불가능

제일 중요한 차이점은 클래스는 참조 타입이고 구조체는 값 타입이라는 것이다.

따라서 구조체의 인스턴스는 스택에 할당되고 인스턴스가 선언된 블록이 끝나는 지점에 메모리에서 사라진다.

인스턴스의 사용이 끝나면 즉시 메모리에서 제거된다는 점과 가비지 콜렉터를 덜 동작하게 한다는 점에서 구조체는 클래스에 비해 성능의 이점을 지닌다.

구조체는 값 타입이기 때문에 할당 연산자 '='를 통해 모든 필드가 그대로 복사된다.

MyStruct s;
s.MyField1 = 1;
s.MyField2 = 2;

MyStruct t;
t = s;
s.MyField1 = 3; 

/* 
s의 MyField1은 3, MyField2는 2
t의 MyField1운 1, MyField2는 2
*/

구조체는 생성자를 호출할 때가 아니면 굳이 new  연산자를 사용하지 않아도 인스턴스를 만들 수 있다.

구조체는 매개변수가 없는 생성자는 선언할 수 없다. 걱정 안해도 되는게 구조체의 각 필드는 CLR이 기본값으로 초기화해준다.

 

객체는 속성과 기능으로 이루어진다. 다른 말로 속성은 상태(State), 기능은 행위(Behavior)라고도 한다.

객체의 속성은 필드와 프로퍼티를 통해 표현되므로, 객체의 상태 도한 필드와 프로퍼티를 통해 표현된다고 할 수 있다.

상태의 변화를 허용하는 객체를 변경가능(Mutable) 객체라고 하며, 그렇지 않은 객체를 변경불가능(Immutable) 객체라고 한다. 변경불가능 객체의 효용은 여러가지가 있다. 그중에서는 멀티 스레드 간에 동기화(Synchronization)를 할 필요가 없기 때문에 프로그램 성능 향상이 가능하고, 무엇보다 버그로 인한 상태(데이터)의 오염을 막을 수 있다. 프로그램의 버그를 유발시키는 상태 오염의 원인을 찾기 위해 수많은 스레드를 디버깅해야 하는 괴로움과 성능 최적화를 위해 코드를 쥐어짜야 하는 괴로움을 겪지 않아도 된다.

 

구조체는 모든 필드와 프로퍼티의 값을 수정할 수 없는, 즉 변경불가능 구조체로 선언할 수 있다. 이에 반해 클래스는 변경불가능으로 선언할 수 없다.

구조체를 선언할 때 readonly 키워드만 입력하면 된다.

readonly struct 구조체_이름
{
}

readonly를 이용해서 구조체를 선언하면, 컴파일러는 해당 구조체의 모든 필드가 readonly로 선언되도록 강제한다.

readonly로 선언된 구조체 안에서 readonly로 선언되지 않은 필드와 프로퍼티는 컴파일 에러를 일으킨다.

readonly struct ImmutableStruct
{
    public readonly int ImmutableField; // OK
    public int MutableField; // 컴파일 에러
}

일기 전용으로 선언된 필드를 수정하려는 시도에 대해서도 컴파일 에러가 발생한다.

 

읽기 전용 메소드 역시 구조체에서만 선언할 수 있다.

readonly 한정자를 이용해서 메소드에게 상태를 바꾸지 않도록 강제하면 방지할 수 있다.

public readonly double GetFahrenheit()
{
	// target은 클래스에 정의된 필드
    target = currentInCelsius * 1.8 + 32; // 컴파일 에러
    return target;
}

 

7.18 튜플

튜플(Tuple)도 여러 필드를 담을 수 있는 구조체다. 구조체와는 달리 튜플은 타입 이름이 없다. 그래서 튜플은 응용 프로그램 전체에서 사용할 타입을 선언할 때가 아닌, 즉석에서 사용할 복합 데이터 타입을 선언할 때 적합하다.

튜플은 구조체이므로 값 타입이다. 값 타입은 생성된 지역을 벗어나면 스택에서 소멸되기 때문에 프로그램에 장기적인 부담을 주지 않는다는 장점이 있다.

// 컴파일러가 튜플의 모양을 보고 직접 타입을 결정하도록 var를 이용하여 선언
var tuple = (123, 789); // 튜플은 괄호 사이에 두 개 이상의 필드를 지정함으로써 만들어진다.

필드의 이름을 지정하지 않는 튜플을 일컬어 '명명되지 않은 튜플(Unnamed Tuple)'이라고 한다.

위 코드의 경우, C# 컴파일러는 자동으로 123을 Item1이라는 필드에 넣고, 789를 Item2라는 필드에 담는다.(튜플은 System.ValueTuple 구조체를 기반으로 만들어지기 때문)

var tuple = (123, 789);
Console.WriteLine($"{tuple.Item1}, {tuple.Item2}"); // "123, 789" 출력

 

튜플을 조금 더 이쁘게 선언하는 방법은 필드의 이름을 지정할 수 있는 명명된 튜플(Named Tuple)이다.

'필드명:'의 꼴로 필드의 이름을 지정하여 선언한다.

var tuple = (Name: "엄준식", Age: 17);
Console.WriteLine($"{tuple.Name}, {tuple.Age}"); // 엄준식, 17

 

튜플을 분해할 수도 있다.

var tuple = (Name: "엄준식", Age: 17);
var (name, age) = tuple;
Console.WriteLine($"{name}, {age}"); // 엄준식, 17

 

튜플을 분해할 때 특정 필드를 무시하고 싶다면 '_'를 이용하면 된다.

var tuple = (Name: "엄준식", Age: 17);
var (name, _) = tuple; // Age 필드 무시
Console.WriteLine($"{name}") // 엄준식

 

튜플 분해를 이용하면 여러 변수를 단번에 생성하고 초기화할 수 있다. 즉석에서 튜플을 만들어서 분해하는 것이다.

var (name2, age2) = ("엄준식", 23);
Console.WriteLine($"{name2}, {age2}"); // 엄준식, 23

 

명명되지 않은 튜플과 명명된 튜플끼리는 필드의 수와 타입이 같으면 할당 가능하다.

var unnamed = ("가재맨", 29); // (string, int)
var named = (Name: "엄준식", Age: 17) // (string, int)

named = unnamed;
Console.WriteLine($"{named.Name}, {named.Age}"); // 가재맨, 29

named = ("준식이", 35);

unnamed = named;
Console.WriteLine($"{unnamed.Name}, {unnamed.Age}"); // 준식이, 35

 

튜플이 분해가 가능한 이유는 분해자(Deconstructor)를 구현하고 있기 때문이다.

분해자를 구현하고 있는 객체를 분해한 결과를 switch 문이나 switch  식의 분기 조건에 활용할 수 있다.

이를 위치 패턴 매칭(Positional Pattern Matching)이라 한다. 식별자나 데이터 타입이 아닌 분해된 요소의 위치에 따라 값이 일치하는지를 판단하는 것이다.

var alice = (job: "학생", age: 17);

var discountRate = alice switch
                {
                    ("학생", int n) when n < 18 => 0.2,  // 학생 && 18세 이상
                    ("학생", _)                 => 0.1,  // 학생 && 18세 이상
                    ("일반", int n) when n < 18 => 0.1,  // 일반 && 18세 미만
                    ("일반", _)                 => 0.05, // 일반 && 18세 이상
                    _ => 0,
                };