클래스 설계에 있어 몇 가지의 설계 지침이 존재한다.

컴파일러가 자동으로 생성하는 멤버 함수들

오늘은 그거에 대해서 정리해 보자.

 

디폴트 생성자

 

매개변수를 전혀 사용하지 않거나, 모든 매개변수가 디폴트 매개변수를 사용하는 생성자다.

사용자가 생성자를 하나도 정의하지 않으면, 사용자를 대신하여 컴파일러는 디폴트 생성자를 정의한다.

디폴트 생성자의 존재는 사용자가 객체를 생성하는 것을 허용한다.

 

모든 기초 클래스를 위한 디폴트 생성자를 호출하는 것과, 다른 클래스의 객체인 멤버들을 호출하는 것도 수행한다.

멤버 초기자 리스트를 사용하여 기초 클래스 생성자를 명시적으로 호출하지 않고 파생 클래스 생성자를 작성하게 되면

컴파일러는 새 객체의 기초 클래스 부분을 생성하기 위해 기초 클래스 디폴트 생성자를 사용하게 된다.

 

사용자가 만약에 생성자를 정의하게 되면, 컴파일러는 디폴트 생성자를 정의하지 않음에 유의하자.

이때에는 디폴트 생성자를 제공하는 것이 프로그래머의 책임으로 된다.

 

생성자를 정의하는 이유는 객체가 항상 바르게 초기화되도록 하기 위한 것도 있다.

특히 클래스가 포인터 멤버를 가지고 있을 경우에는 확실하게 초기화되어야 한다.

모든 클래스 데이터 멤버를 의미 있는 값으로 초기화시키는, 명시적 디폴트 생성자를 제공하는 것이 바람직하다.

 

복사 생성자

 

클래스형의 객체를 매개변수로 사용하는 생성자.

  • 새 객체를 동일한 클래스의 다른 객체로 초기화할 때
  • 객체가 함수에 값으로 전달할 때
  • 함수가 객체를 값으로 리턴할 때
  • 컴파일러가 임시 객체를 생성할 때

프로그램이 복사 생성자를 (명시적 or 암시적) 사용하지 않으면, 컴파일러는 함수 정의가 아니라 원형만 제공한다.

그렇지 않으면, 프로그램은 멤버별 초기화를 수행하는 복사 생성자를 정의한다.

 

멤버별 초기화가 좋지 못한 상황이 존재하는데,

new에 의해 초기화되는 멤버 포인터들은, 일반적으로 깊은 복사를 요구한다.

이럴 때에는 복사 생성자를 직접 정의할 필요가 있다.

대입 연산자

 

디폴트 대입 연산자는 어떤 객체를 동일한 클래스의 다른 객체에 대입하는 것을 처리한다.

대입과 초기화를 혼동하면 안 된다!

구문이 새 객체를 생성한다면 그것은 초기화를 사용하는 것이고,

구문이 기존 객체의 값을 변경한다면, 그것은 대입이다.

Star sirius;
Star alpha = sirius; // 초기화

Star dogstar;
dogstar = sirius; // 대입

디폴트 대입은 멤버 간 대입을 사용한다.

어떤 멤버가 그 자체로 클래스 객체일 경우에는 디폴트 멤버간 대입은 해당 클래스에 정의된 대입 연산자를 사용하게 된다.

 

복사 생성자를 명시적으로 정의할 필요가 있다면, 같은 이유로 대입 연산자도 명시적으로 정의할 필요가 있다.

Star & Star::operator=(const Star&);

컴파일러는 하나의 데이터형을 다른 데이터형에 대입하는 대입 연산자는 생성하지 않는다.

 

클래스 메서드에 관련된 그 밖의 고려 사항

 

# 생성자 관련 사항

생성자들은 새로운 객체를 생성한다는 점에서 클래스의 다른 메서드들과 구별된다.

상속은 파생 클래스 객체가 기초 클래스 메서드를 사용할 수 있다는 것을 의미한다. 그런데 생성자의 경우에 그 객체는 생성자가 자신의 일을 끝낸 후까지 존재하지 않는다.

 

# 파괴자 관련 사항

클래스 생성자에서 new에 의해 대입되는 메모리를 해제하고, 클래스 객체의 파괴에 따르는 특별한 정리 작업을 처리하기 위해서

명시적 파괴자를 반드시 정의해야 한다는 것을 기억하자.

특히 그 클래스를 기초 클래스로 사용할 예정이라면, 가상 파괴자를 제공하자.

 

# 변환 관련 사항

정확히 하나의 매개변수를 사용하여 호출할 수 있는 생성자는, 그 매개변수의 데이터형을 클래스형으로 변환하는 것을 정의한다.

Star(const char *); // const char *를 Star로 변환
Star(const Spectral&, int members = 1); // Spectral을 Star로 변환

Star north;
north = "polaris"

1. Star::Star(const char *)를 사용해서 Star 객체를 생성하고,
2. 생성된 Star 객체를 매개변수로 사용하여 Star::operator=(const Star&) 메서드를 호출하게 된다.
단, 이것은 (char *)를 Star에 대입하는 대입 연산자를 정의하지 않았을 때에만 해당

만약에 explicit 키워드를 사용한다면

public:
	explicit Star(const char *);
    
    
north = "polaris";       // 허용되지 않고
north = Star("polaris"); // 허용된다.

 

클래스 객체를 다른 데이터형으로 변환하려면, 변환 함수를 정의해야 한다.

변환 함수는 매개변수를 사용하지 않고, 변환 결과 데이터형의 이름을 리턴형으로 선언하지 않는 클래스 멤버 함수다.

Star::Star double() const { ... } // Star를 double로 변환
Star::Star const char * () const {...} // Star를 const char *로 변환

변환 함수는 이치에 맞을 경우에만 신중하게 사용하는 것이 좋다. 그렇지 않으면 모호한 코드를 작성할 확률이 높아진다고 한다.

이때 explicit 키워드를 사용한다면 명시적 변환만 허용하기 때문에 조금 더 신중하게 작성될 수 있다.

 

값으로 전달, 참조로 전달

 

객체를 값으로 전달하지 않고 참조로 전달해야 한다. 이는 효율성

객체를 값으로 전달하면 임시 복사본이 생긴다. 복사 생성자를 호출하고, 나중에 파괴자를 호출한다는 것을 의미

큰 객체의 복사는 참조로 전달할 때보다 속도가 많이 느려질 수 있다.

그러므로 함수가 객체를 변경하지 않는다면, 그 함수의 매개변수를 const 참조로 선언해야 한다.

 

가상 함수를 사용하는 상속의 경우에, 기초 클래스 참조 매개변수를 받아들이도록 정의된 함수는,

파생 클래스에 대해서도 성공적으로 사용이 가능하다. (전정한 가상 함수의 힘 - 동적 결합이 실현된다.)

 

객체 리턴과 참조 리턴

 

참조를 리턴하는 것이 더 좋다.

객체를 리턴하게 되면 리턴된 객체의 임시 복사본을 생성하기 때문이다.

그 복사본은 호출한 프로그램에서 사용할 수 있는데, 객체를 리턴하면 복사 생성자를 호출하여 복사본을 생성하는 시간 부담과,

나중에 파괴자를 호출하여 그 복사본을 없애는 시간 부담이 따른다.

참조로 리턴하게 되면, 시간과 메모리가 절약된다.

 

객체를 직접 리턴하는 것은 객체를 값으로 전달하는 것과 비슷하다.

둘 다 임시 복사본을 생성하며, 참조를 리턴하는 것은 객체를 참조로 전달하는 것과 비슷하다.

 

주의할 점은

함수는 그 함수 안에서 생성된 임시 객체에 대한 참조를 리턴하면 안 된다.

함수가 종료되어 그 객체가 파괴되면 더 이상 참조가 유효하지 않기 때문이다.

이러한 코드의 경우 호출한 함수에서 사용할 수 있는 복사본을 생성할 수 있도록 객체를 리턴해야 옳다.

 

함수가 그 함수 안에서 생성된 임시 객체를 리턴한다면, 참조를 사용해서는 안 된다.

Vector Vector::operator+(const Vector& b) const
{
	return Vector(x + b.x, y + b.y);
}

 

참조나 포인터를 통해 전달된 객체를 함수가 리턴한다면, 그 객체를 참조로 리턴해야 한다.

const Stock& Stock::topval(const Stock& s) const
{
    if (s.total_val > total_val)
    	return s;     // 매개변수로 전달된 객체
    else
    	return *this; // 호출한 객체
}

 

const의 사용

 

const를 사용할 기회가 있을 때 신중해질 필요가 있다.

메서드가 매개변수를 변경하지 않는다는 확신이 있을 때, 그것을 사용할 수 있다.

Star::Star(const char * s) { ... } //s가 지시하는 문자열을 변경하지 않는다.

메서드가 그것을 호출한 객체를 변경하지 않는다는 확신이 있을 때, const를 사용할 수 있다.

void Star::show() const {...} // 호출한 객체를 변경하지 않는다.

여기서 사용된 const는 const Star * this를 의미하고, this는 호출한 객체를 지시한다.

일반적으로, 참조를 리턴하는 함수는 대입 구문의 왼쪽에 올 수 있다.

 

객체 안의 데이터를 변경하는 데 참조나 포인터 리턴값을 사용할 수 없도록 하기 위해서 const를 사용할 수 있다.

const Stock & Stock::topval(const Stock& s) const
{
}

함수의 매개변수를 const에 대한 참조나 const를 지시하는 포인터로 선언하면,

매개변수를 변경하지 않는다는 보장이 없는 경우에 그 매개변수를 다른 함수에 전달할 수 없다.

 

public 상속에 관련된 그 밖의 고려 사항들

 

# is - a 관계

파생 클래스가 기초 클래스의 특별한 종류가 아니면 public 파생을 사용하면 안 된다.

어떤 경우에는 순수 가상 함수를 가지고 있는 추상화 기초 클래스를 생성하고, 그것으로부터 다른 클래스를 파생시키는 것이 최선의 방법일 수 있다.

 

명시적 데이터형 변환 없이 기초 클래스 포인터가 파생 클래스 객체를 지시할 수 있고,

기초 클래스 참조가 파생 클래스 객체를 참조할 수 있다는 것이 is-a 관계의 특성이다.

다만 그 역은 성립되지 않는다.

명시적인 데이터형 변환 없이는 파생 클래스 포인터나 참조가 기초 클래스 객체를 참조할 수 없다.

다운캐스팅이 이치에 맞을 수도 있고 아닐 수 도 있다.

 

# 상속되지 않는 것

생성자는 상속되지 않는다.

일반적으로 파생 클래스 생성자는 파생 클래스 객체의 기초 클래스 부분을 생성하기 위해, 

멤버 초기자 리스트 문법을 사용하여 기초 클래스 생성자를 호출한다.

 

파생 클래스 생성자가 멤버 초기자 리스트 문법을 사용하여 기초 클래스 생성자를 명시적으로 호출하지 않는다면

기초 클래스의 디폴트 생성자를 사용하게 된다.

 

파괴자는 상속되지 않는다.

컴파일러는 디폴트 파생 클래스 파괴자를 생성한다.

일반적으로 어떤 클래스가 기초 클래스의 역할을 한다면, 그 파괴자는 가상이어야 한다.

 

대입 연산자는 상속되지 않는다.

상속받은 메서드는 기초 클래스와 파생 클래스에서 동일한 함수 시그내처를 사용한다.

그러나 대입 연산자는 그 클래스형의 형식 매개변수를 가지기 때문에, 각 클래스마다 다른 함수 시그내처를 가진다.

 

# 대입 연산자 관련 사항

한 객체를 동일한 클래스의 다른 객체에 대입하는 것을 컴파일러가 발견하면, 

컴파일러는 그 클래스에 대해 자동으로 대입 연산자를 제공한다. (멤버별 대입, 암시적 디폴트 버전)

 

기초 클래스를 위한 대입 연산자를 명시적으로 제공하지 않으면, 그 연산자가 사용된다.

같은 논리로, 어떤 클래스가 다른 클래스의 객체를 멤버로 가지고 있을 때, 그 멤버의 대입을 위해서 그 멤버 클래스의 대입 연산자가 사용된다.

 

중요. 클래스 생성가 new를 사용하여 포인터를 초기화하고 있다면, 명시적 대입 연산자를 제공해야 한다.

 

파생 클래스 객체를 기초 클래스 객체에 대입한다면?

대입 구문은 왼쪽에 있는 객체에 의해 호출되는 메서드로 번역된다는 사실을 기억하자.

 

역으로, 파생 클래스 참조는 기초 클래스 객체를 자동으로 참조할 수 없기 때문에,

변환 생성자가 준비되어 있지 않다면 코드를 실행되지 않는다.

 

# 가상 메서드

파생 클래스에서 메서드를 다시 정의하려면, 기초 클래스에서 그 메서드를 가상으로 정의하자.

이것은 동적 결합을 가능케 한다.

 

코드를 동적 결합하게 하려면, 객체를 참조나 포인터 형태로 전달해야 한다.

 

# 파괴자 관련 사항

기초 클래스 파괴자는 가상이어야 한다.

객체에 대한 기초 클래스 포인터나 참조를 통해 파생 클래스 객체를 파괴할 때, 프로그램은 기초 클래스 파괴자만 사용하는 것이 아니라,

파생 클래스 파괴자를 먼저 사용하고, 뒤이어 기초 클래스 파괴자를 사용한다.

 

# 프렌드 관련 사항

프렌드 함수는 클래스 멤버가 아니기 때문에 상속되지 않는다.

다만 파생 클래스의 프렌드를 기초 클래스의 프렌드로 사용하기를 원할 수 있는데,

이때에는 데이터형 캐스팅을 실시하고 사용하면 된다.

 

# 기초 클래스 메서드의 사용

  • 파생 클래스 객체는 그 파생 클래스가 메서드를 다시 정의하지 않는다면, 상속된 기초 클래스 메서드를 자동으로 사용한다.
  • 파생 클래스 파괴자는 기초 클래스 파괴자를 자동으로 호출한다.
  • 파생 클래스 생성자는 멤버 초기자 리스트에서 다른 생성자를 지정하지 않는다면 기초 클래스 디폴트 생성자를 자동으로 호출한다.
  • 파생 클래스 생성자는 멤버 초기자 리스트에 지정된 기초 클래스 생성자를 명시적으로 호출한다.
  • 파생 클래스 메서드들은 public, protected 기초 클래스 메서드들을 호출하기 위해 사용 범위 결정 연산자를 사용할 수 있다.
  • 파생 클래스의 프렌드는 파생 클래스 참조나 포인터를 기초 클래스 참조나 포인터로 데이터형을 캐스트 하고 그렇게 변환된 참조나 포인터를 사용하여 기초 클래스의 프렌드를 호출할 수 있다. 

'CS > C++' 카테고리의 다른 글

클래스 상속  (0) 2024.07.27
접두어 방식과 접미어 방식의 차이  (1) 2023.03.05

항상 상속, 상속하지만 정말 어디까지 깊게 가봤을까..?

 

이번에 비록 공부했던 내용을 정리하면서, 조금 더 알아가고 싶어서 시작해 보자.

* 정리 내용은 C++ 기초 플러스를 기반으로 정리하고 있습니다.

 

C++에서는 클래스는 더 높은 수준의 재활용성을 제공합니다.

여기서는 C++는 클래스를 확장시키고, 수정하기 위해서 클래스 상속(class inheritance)을 사용하고 있다.

 

상속이라는 걸 여기에 담고 싶어서 본문의 내용이 엄~청 길어질 수 있다.

다만 여러 차례로 나눠서 하는 것보다 흐름이 끊기지 않게 보는 것이 더 이득이라 판단하여 계속해서 포스팅 예정

상속이 주는 힘이 모길래...

 

  1. 기존 클래스에 기능을 추가할 수 있다.
  2. 클래스가 나타내는 데이터에 다른 것을 더 추가할 수 있다.
  3. 클래스 메서드가 동작하는 방식을 변경할 수 있다.

즉, 상속은 새 기능을 간단히 추가하는 것으로 모든 것? 이 해결된다고 하는데,

제가 느끼기에는 기존 코드를 건드리지 않고, 기존 코드 + 내가 만든 확장성 기능으로 이미 사용된 것에 ++ 하는 느낌으로 개선된다라고 생각됩니다.

=> 세부 구현의 비밀은 유지하면서, 프로그래머들이 그 클래스에 기능을 추가하여 새로운 클래스를 파생시킬 수 있게

(기존 코드를 맘대로 수정했다가, 생각지도 못한 케이스에서 예외나 버그가 발생할 수 있기 때문에)

 

이렇듯 상속은 뛰어난 개념이지만, 기본적인 구현은 단순 

 

가볍게 상속을 들어가 보자.

일반적으로 오리지널 클래스를 기초 클래스(Base class)라고 하고,

이걸 상속받는 클래스를 파생 클래스(Derived class)라고 한다.

 

상속 관련 기본적인 코드를 살펴보자.

#ifndef TABTENN1_H_
#define TABTENN1_H_ 
#include <string>
using std::string;

class TableTennisPlayer
{
private:
	string firstname;
	string lastname;
	bool hasTable;

public:
	TableTennisPlayer(const string& fn = "none", const string& ln = "none", bool ht = false);
	void Name() const;
	bool HasTable() const { return hasTable; }
	void ResetTable(bool v) { hasTable = v; }
};

// 파생 클래스
// 두 클래스는 서로 연계되어 있기 때문에, 클래서 선언들을 함께 묶어두는 것이 더 체계적이라고 한다.
class RatedPlayer : public TableTennisPlayer
{
private:
	unsigned int rating;

public:
	RatedPlayer(unsigned int r = 0, const string& fn = "none", const string& ln = "none", bool ht = false);
	RatedPlayer(unsigned int r, const TableTennisPlayer& tp);
	unsigned int Rating() { return rating; }
	void ResetRating(unsigned int r) { rating = r; }
};

#endif
#include "tabtenn1.h"
#include <iostream>

TableTennisPlayer::TableTennisPlayer(const string& fn, const string& ln, bool ht)
	:firstname(fn), lastname(ln), hasTable(ht) {}

void TableTennisPlayer::Name() const
{
	std::cout << lastname << ", " << firstname;
}

RatedPlayer::RatedPlayer(unsigned int r, const string& fn, const string& ln, bool ht)
	: TableTennisPlayer(fn, ln, ht)
{
	rating = r;
}

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer& tp)
	: TableTennisPlayer(tp), rating(r) {}

파생 클래스 RatedPlayer를 선언하게 되면, 다음과 같은 효과를 얻을 수 있다.

  • 파생 클래스형의 객체 안에는 기초 클래스형의 데이터 멤버들이 저장된다. (상속받았으니까)
  • 파생 클래스형의 객체는 기초 클래스형의 메서드들을 사용할 수 있다.

상속받은 기능에 어떤 것들을 추가해야 할까?

  • 파생 클래스는 자기 자신의 생성자를 필요로 한다.
  • 파생 클래스는 부가적인 데이터 멤버들과 멤버 함수들을 필요한 만큼 추가할 수 있다.

* 생성자

파생클래스는 기초 클래스의 private 멤버에 직접 접근이 불가능하다. (public, protected만 가능)

파생클래스 객체를 생성할 때,

 

기초 클래스의 객체를 생성하게 된다.

RatedPlayer::RatedPlayer(unsigned int r, const string& fn, const string& ln, bool ht)
	: TableTennisPlayer(fn, ln, ht)
{
	rating = r;
}

즉, 파생 클래스 생성자의 몸체 안으로 들어가기 전에, 기초 클래스 객체가 먼저 생성되어야 하기 때문에

멤버 초기자 리스트 문법을 사용해서, 기초 클래스의 생성자에게 매개 변수를 전달한다.

만약 멤버 초기자 리스트를 생략한다면? 기초 클래스 생성자가 생성되는 것은 확실하고 다만 디폴트 기초 클래스 생성자를 사용하게 된다.

 

디폴트 기초 생성자는 프로그래머가 아무것도 정의하지 않을 때에만 컴파일러가 자동적으로 생성해 주는 것이며, 만약에 이를 사용하기를 원치 않으면 직접 생성자를 구현해 주면 된다.

 

* 복사 생성자

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer& tp)
	: TableTennisPlayer(tp), rating(r) {}

TableTennisPlayer(tp)

tp는 const TableTennisPlayer& 형이므로 이 호출은 기초 클래스의 복사 생성자를 호출하게 된다.

마찬가지로 복사 생성자를 정의하지 않았다면 컴파일러가 복사 생성자를 자동으로 생성하게 되는데

이게 좋은 점과 나쁜 점이 있다.

 

보통 기본 복사 생성자는 멤버별 복사가 이뤄지는데 (primitive type)이라면 상관없지만,

생성자 안에서 new를 이용한 동적 메모리 대입이 이뤄진다면? 문제가 된다.

여기서는 동적 메모리 대입이 없기 때문에 암시적 복사 생성자로 충분하다.

 

여기까지 정리하면,

파생 클래스 생성자의 요점은

  • 기초 클래스 객체가 먼저 생성된다.
  • 파생 클래스 생성자는 멤버 초기자 리스트를 통해 기초 클래스 생성자에게 정보를 제공해야 한다.
  • 파생 클래스 생성자에서는 새로 추가된 데이터 멤버들을 초기화해야 한다. (생성자의 의도는 초기화니까)
  • 객체의 생성은 기초 클래스 -> 파생 클래스 순이지만 파괴는 파생 클래스 -> 기초 클래스 순으로 이뤄진다.
파생 클래스와 기초 클래스의 특별한 관계?

기초 클래스 메서드들이 private가 아니라면, 파생 클래스 객체는 기초 클래스의 메서드를 사용할 수 있다.

이건 당연하다.

 

기초 클래스 포인터는 명시적 데이터형 변환 없이도 파생 클래스 객체를 지시할 수 있다.

기초 클래스 참조는 명시적 데이터형 변환 없이도 파생 클래스 객체를 참조할 수 있다.

RatedPlayer rplayer1(1140, "mallory", "Duck", true);
TableTennisPlayer * pt = &rplayer1;
TableTennisPlayer & rt = rplayer1;

pt->Name(); // 포인터
rt.Name();  // 참조

다만, 기초 클래스 포인터나 참조는 기초 클래스 메서드만 호출할 수 있다.

(파생 클래스의 메서드를 사용할 수 없다는 것이다. 처음에는 이게 이해하기 어려웠다.)

 

일반적으로 C++은 참조형과 포인터형이 대입되는 데이터형과 일치할 것을 요구한다.

그러나 상속에서는 이 규칙이 완화 적용되는데

파생 클래스의 참조와 포인터에 기초 클래스의 객체와 주소를 대입할 순 없지만 반대는 된다.

 

기초 클래스 참조와 포인터를 매개변수로 사용하는 함수는 기초 클래스 or 파생 클래스 모두 넘겨줄 수 있다.

즉, 메서드 하나만 이렇게 구현하면 파생된 모든 클래스를 담을 수 있다는 얘기다.

 

참조 호환성은

기초 클래스 객체를 파생 클래스 객체로 초기화도 간접적으로 허용한다.

RatedPlayer olaf1(...);
TableTennisPlayer olfa2(olaf1);

// 암시적 복사 생성자
TableTennisPlayer(const TableTennisPlayer&);

형식 매개변수를 보면 기초 클래스형에 대한 참조로 되어있다. 그래서 파생 클래스가 객체도 담을 수 있는 것이다.

 

비슷한 코드이지만 대입이라는 게 생성자와는 다르다.

RatedPlayer olaf1(...);
TableTennisPlayer winner;
winner = olaf1; // 파생 클래스 객체를 기초 클래스 객체에 대입

// 암시적인 오버로딩 대입 연산자
TableTennisPlayer& operator=(const TableTennisPlayer&) const;

 

암시적인 복사 생성자, 암시적인 오버로딩 대입 연산자.

편하지만, new라는 동적이 들어가면 명시적으로 구현해줘야 하는 게 핵심이다.

 

상속 is-a 관계

is-a 관계,

파생 클래스 객체가 기초 클래스 객체이기도 하다는 것을 뜻한다.

기초 클래스 객체를 대상으로 수행할 수 있는 모든 작업을, 파생 클래스 객체에서도 수행이 가능하다는 것이다.

 

has-a 관계도 존재하는데,

이거는 클래스가 다른 클래스를 가졌다는 의미이지 ~가 ~일 수는 없는 개념이므로 상속과는 다르다.

다만 서로 다른 객체 간의 통신(소통)을 다루기 위해서는 프렌드 함수나 클래스를 만드는 것은 가능하다.

public 다형 상속

호출하는 객체에 따라서 메서드의 행동이 달라질 수 있다는 것이 요점이다.

이렇게 복잡한 행동을 다형(polymorhpic)이라고 한다.

 

public 다형 상속으로는

  • 기초 클래스 메서드를 파생 클래스에서 다시 정의하는 것
  • 가상 메서드를 사용하는 것(virtual)

예시 코드를 살펴보자.

(사실 코드의 내용도 중요하지만, 전체적인 흐름 위주로 공부하는 것이 오히려 더 도움이 되는 듯하다.)

(즉, 외우는 것보다는 전체적인 그림과 왜 이렇게 선언했는지를 중점으로 보자.)

#ifndef BRASS_H_
#define BRASS_H_
#include <string>
class Brass
{
private:
	std::string fullName;
	long acctNum;
	double balance;

public:
	Brass(const std::string& s = "Nullbody", long an = -1, double bal = 0.0);
	void Deposit(double amt);
	virtual void Withdraw(double amt);
	double Balance() const;
	virtual void ViewAcct() const; // 가상 함수(가상 메서드)
	virtual ~Brass() {}
};

// Brass::ViewAcct()
// BrassPlus::ViewAcct()
// 호출한 객체가 어떤 클래스형이냐에 따라 어느 버전을 사용할 것인지를 결정
// Withdraw도 동일

// Deposit(), Balance()
// 두 클래스에 대해 똑같이 행동하는 메서드들은 기초 클래스에 단 한 번만 선언된다.

class BrassPlus : public Brass
{
private:
	double maxLoan;
	double rate;
	double owesBank;

public:
	BrassPlus(const std::string& s = "Nullbody", long an = -1, double bal = 0.0, double m1 = 500, double r = 0.11125);
	BrassPlus(const Brass& ba, double m1 = 500, double r = 0.11125);
	virtual void ViewAcct() const;
	virtual void Withdraw(double amt);
	void ResetMax(double m) { maxLoan = m; }
	void ResetRate(double r) { rate = r; }
	void ResetOwes() { owesBank = 0; }
};
#endif

헤더 파일에서만 살펴보면,

기초는 Brass, 파생은 BrassPlus

파생 클래스에서는 3개의 새로운 데이터 멤버와 멤버 함수를 추가하였다.

 

그런데, 기초/파생 모두 ViewAcct()와 Withdraw() 메서드를 선언하고 있는데

이는 각 객체에 대해 하는 행동이 다른 메서드들이라는 뜻이다.

그래서 virtual 키워드로 가상 메서드로 선언하고 있다.

Brass::ViewAcct() // BrassPlus::ViewAcct() 다른 내용...

프로그램은 호출한 객체가 어떤 클래스형이냐에 따라서 어느 버전을 사용할지 결정하게 된다.

 

Deposit과 Balance 메서드는 한 번만 기초 클래스에서 선언되었는데, 이는 두 클래스에 대해 똑같이 행동하는 메서드들이기 때문이다.

 

또 파괴자에서도 virtual 키워드를 사용하는데,

아무 일도 하지 않지만 가상 파괴자를 선언하고 있다.

#include "brass.h"
#include <iostream>
using std::cout;
using std::endl;
using std::string;

typedef std::ios_base::fmtflags format;
typedef std::streamsize precis;
format setFormat();
void restore(format f, precis p);

Brass::Brass(const string& s, long an, double bal)
{
	fullName = s;
	acctNum = an;
	balance = bal;
}

void Brass::Deposit(double amt)
{
	if (amt < 0)
		cout << "마이너스 입금은 허용되지 않습니다.\n그래서 입금을 취소되었습니다.\n";
	else
		balance += amt;
}

void Brass::Withdraw(double amt)
{
	// ###.## 형식으로 설정
	format initialState = setFormat();
	precis prec = cout.precision(2);

	if (amt < 0)
		cout << "마이너스 인출은 허용되지 않습니다.\n그래서 인출이 취소되었습니다.\n";
	else if (amt <= balance)
		balance -= amt;
	else
		cout << "인출을 요구한 금액 $" << amt
		<< "가 현재 잔액을 초과합니다.\n"
		<< "그래서 인출이 취소되었습니다.\n";

	restore(initialState, prec);
}

double Brass::Balance() const
{
	return balance;
}

void Brass::ViewAcct() const
{
	format initialState = setFormat();
	precis prec = cout.precision(2);
	cout << "고객 이름: " << fullName << endl;
	cout << "계좌 번호: " << acctNum << endl;
	cout << "현재 잔액: $" << balance << endl;
	restore(initialState, prec); // 원래 형식으로 복원
}

BrassPlus::BrassPlus(const string& s, long an, double bal, double m1, double r)
	: Brass(s, an, bal)
{
	maxLoan = m1;
	owesBank = 0.0;
	rate = r;
}

BrassPlus::BrassPlus(const Brass& ba, double m1, double r)
	: Brass(ba) // 암시적 복사 생성자를 사용한다.
{
	maxLoan = m1;
	owesBank = 0.0;
	rate = r;
}

void BrassPlus::ViewAcct() const
{
	format initialState = setFormat();
	precis prec = cout.precision(2);

	Brass::ViewAcct(); // 기초 부분 출력
	cout << "당좌 대월 한도액: $" << maxLoan << endl;
	cout << "상환할 원리금: $" << owesBank << endl;
	cout.precision(3);
	cout << "당좌 대월 이자율: " << 100 * rate << "%\n";
	restore(initialState, prec);
}

void BrassPlus::Withdraw(double amt)
{
	format initialState = setFormat();
	precis prec = cout.precision(2);

	// Balance() 메서드
	// 파생 클래스에서 다시 정의되지 않기 때문에,
	// 사용 범위 결정 연산자(::)를 사용할 필요가 없다.
	double bal = Balance();
	if (amt <= bal)
		Brass::Withdraw(amt);
	else if (amt <= bal + maxLoan - owesBank)
	{
		double advance = amt - bal;
		owesBank += advance * (1.0 + rate);
		cout << "당좌 대월 금액: $" << advance << endl;
		cout << "당좌 대월 이자: $" << advance * rate << endl;
		Deposit(advance);
		Brass::Withdraw(amt);
	}
	else
	{
		cout << "당좌 대월 한도가 초과되어 거래가 취소되었습니다.\n";
	}

	restore(initialState, prec);
}

format setFormat()
{
	return cout.setf(std::ios_base::fixed, std::ios_base::floatfield);
}

void restore(format f, precis p)
{
	cout.setf(f, std::ios_base::floatfield);
	cout.precision(p);
}

 

중점적으로 봐야 하는 건, virtual 키워드를 사용한 녀석인데

 

  • 메서드가 객체에 의해서 호출되지 않고 참조나 포인터에 의해 호출되었을 때에는 어느 메서드를 사용할 것인지를 결정한다.
  • virtual 키워드를 사용하지 않을 경우에는 프로그램은 참조형이나 포인터형에 기초하여 메서드를 선택한다.

ViewAcct()가 가상이 아닐 때에는

참조형 혹은 포인터형에 기초하여 메서드가 선택된다.

# 가상 메서드가 아닌 케이스
Brass dom(...);
Brass dot(...);

Brass& b1 = dom;
Brass& b2 = dot;

b1.ViewAcct(); // Brass::ViewAcct() 호출
b2.ViewAcct(); // Brass::ViewAcct() 호출

참조형이나 포인터형에 기초하여 메서드를 선택하게 된다.

# 가상 메서드인 경우에
Brass dom(...);
BrassPlus dot(...);

Brass& b1 = dom;
Brass& b2 = dot;

b1.ViewAcct(); // Brass::ViewAcct();
b2.ViewAcct(); // BrassPlus::ViewAcct();

둘다 참조 변수 Brass형이지만?
b2는 BrassPlus의 객체를 참조한다.

파생 클래스에서 다시 정의되는 메서드들은, 기초 클래스에서 가상으로 선언하는 것이 일반적인 관행이라고 한다.

 

어떤 메서드가 기초 클래스에서 가상으로 선언되었을 때?

파생 클래스에서 자동으로 가상 메서드가 된다.

그러나 파생 클래스 선언에서도 virtual 키워드를 사용함으로써, 어떤 함수들이 가상인지 표시해 두는 것이 바람직하다.

프로그램이 거대해질수록 이런 사소한 키워드를 놓칠 수 있기 때문에 (휴먼 에러)

 

* 기초 클래스가 가상 파괴자를 선언한다는 것은

파생 클래스 객체가 파괴될 때, 파괴자들이 올바른 순서로 호출되도록 해준다.

 

* 참고로 virtual 키워드는 메서드 정의부가 아니라, 클래서 선언부에 메서드 원형에만 사용하면 된다. 

 

* 다시 한번 리마인드

파생 클래스는 기초 클래스의 private 데이터에 직접적으로 접근하지 못하는데,

파생 클래스 생성자들이 기초 클래스의 private 데이터를 초기화하기 위해서는 멤버 초기자 리스트 문법을 사용하는 것이다.

BrassPlus::BrassPlus(const string& s, long an, double bal, double m1, double r)
	: Brass(s, an, bal)
{
	maxLoan = m1;
	owesBank = 0.0;
	rate = r;
}

BrassPlus::BrassPlus(const Brass& ba, double m1, double r)
	: Brass(ba) // 암시적 복사 생성자를 사용한다.
{
	maxLoan = m1;
	owesBank = 0.0;
	rate = r;
}

 

* 멤버 초기자 리스트

생성자가 아니면, 멤버 초기자 리스트 문법을 사용할 수 없음에 주의하자.

 

* (::) 키워드

사용 범위 결정 연산자

어느 클래스의 어느 걸 사용할 건지를 알려주는 아주 중요한 녀석이다.

예를 들면 기초 클래스에도 있고, 파생 클래스에도 있을 때,

혹은 같은 파일에 정의하고 구현할 때, 등등

 

가상 메서드의 행동

객체에 의한 호출은 가상 함수의 기능을 온전히 사용하지 못한다.

 

Brass와 BrassPlus 객체를 둘 다 지시할 수 있도록 배열을 구성한다면? 이 역시 다형으로 인정된다.

이때, 그 배열에서 아래와 같은 코드를 작성한다면, 가상 메서드의 기능을 발휘할 수 있다.

for (int i = 0; i < CLIMITS; ++i)
{
	p_clients[i]->ViewAcct();
	cout << endl;
}

배열 원소의 포인터가 BrassPlus 객체를 지시하고 있다면 BrassPlus::ViewAcct()가 호출되고,

Brass 객체를 지시하고 있다면 Brass::ViewAcct()가 호출되게 된다.

이것이 다형이다.

 

가상 파괴자는 왜 필요할까

보통 파괴자는 new에 의해 대입된 객체들을 해제하기 위해서 delete를 이쪽에 구현하는데,

파괴자들이 가상이 아니라면, 포인터형에 해당하는 파괴자만 호출될 것이다.

 

BrassPlus 객체를 지시하는 경우에도 포인터형은 Brass이기 때문에, Brass 파괴자만 호출된다.

하지만 파괴자가 가상 파괴자면? 파괴자들이 올바른 순서로 호출되도록 만든다.

 

정적 결합과 동적 결합

소스 코드에 있는 함수 호출을 특정 블록에 있는 함수 코드를 실행하라는 뜻으로 함수 이름을 결합(Binding)이라고 한다.

C언어에서는 각각의 함수가 별개의 함수에 해당하기 때문에, 이 작업이 간단했었다.

 

C++에서는 함수 오버로딩 때문에 이 작업이 조금 복잡하다고 한다.

C or C++ 컴파일러는 결합을 컴파일하는 동안에 수행할 수 있는데,

 

컴파일하는 동안에 일어나는 결합을 정적 결합(Static binding),

가상 함수들은 이 작업을 조금 더 어렵게 만든다.

 

프로그램을 실행할 때, 사용자가 객체를 결정하기 때문에, 컴파일하는 동안에는 그 객체가 어떤 종류인지 알 수 없게 된다.

따라서 어떤 함수를 사용할 것인지 컴파일 시에 결정할 수 없기 때문에

프로그램을 실행할 때 올바른 가상 메서드가 선택되도록 하는 코드를 만들어내야 한다.

이 작업을 동적 결합(dynamic binding)라고 한다.

 

포인터와 참조형의 호환

일반적으로 C++은 한 데이터형의 주소를 다른 데이터형의 포인터에 대입하는 것을 허용하지 않는다.

또한 한 데이터형에 대한 참조가 다른 데이터형을 참조하는 것도 허용하지 않는다.

 

다만 기초 클래스를 지시하는 포인터나 참조는 명시적인 강제 데이터형 변환 없이도

파생 클래스 객체를 참조할 수 있다.

 

파생 클래스 참조나 포인터를 기초 클래스 참조나 포인터로 변환하는 것을 업캐스팅(upcasting)이라고 한다.

public 상속에서는 명시적인 데이터형 변환이 없이도 업캐스팅이 언제든지 가능하다. (is-a관계)

BrassPlus 객체는 Brass객체의 모든 데이터 멤버와 멤버 함수들을 상속받는다는 의미에서 Brass 객체다.

그래서 Brass 객체를 대상으로 할 수 있는 모든 작업을 BrassPlus 객체를 대상으로 할 수 있게 된다.

 

참고로 업캐스팅은 전이된다. 파생 클래스를 끊임없이 만들어도 Brass 포인터는 파생된 모든 객체를 모두 참조할 수 있게 된다.

 

다만,

기초 클래스 포인터나 참조를 파생 클래스 참조나 포인터로 변환하는 것을 다운캐스팅(downcasting)이라 하는데

다운캐스팅은 명시적인 데이터형 변환 없이는 허용되지 않는다.

이는 is-a 관계가 대칭적이지 않기 때문(일방통행)

 

업캐스팅의 힘은 기초 클래스에 대한 참조 또는 포인터를 매개변수로 가지는 함수 호출에서 유용하다.

void fr(Brass& rb);
void fp(Brass* pb);
void fv(Brass b);

Brass b(...);
BrassPlus bp(...);

fr(b);  // Brass::ViewAcct();
fr(bp); // BrassPlus::ViewAcct();
fp(b);  // Brass::ViewAcct();
fp(bp); // BrassPlus::ViewAcct();

fv(b);  // Brass::ViewAcct();
fv(bp); // Brass::ViewAcct();

참조나 포인터에 의해 발생하는 암시적 업캐스팅은 fr(), fp() 메서드가 Brass 객체에 대해서는 Brass::ViewAcct()로

BrassPlus 객체에 대해서는 BrassPlus::ViewAcct()를 사용하게 한다.

 

하지만,

값 전달 방식은 Brass 컴포넌트만 함수로 전달하기 때문에 모두 Brass::ViewAcct()를 호출하게 된다.

 

암시적 업캐스팅은 기초 클래스 포인터나 참조가 기초 클래스 객체나 파생 클래스 객체를 참조하는 것을 가능하게 만든다.

이래서 동적 결합이 필요한 이유이며, 가상 멤버 함수는 이러한 필요성에 대한 C++의 해답으로 생겨놨다.

 

가상 멤버 함수와 동적 결합

컴파일러는 가상이 아닌 함수들에 대해서는 정적 결합을 사용한다.

그러나 기초 클래스에 가상으로 선언한다면?, 프로그램이 실행될 때 결정될 수 있게 변경된다. (동적 결합)

 

대부분의 경우에는 동적 결합이 더 좋다.

특별한 클래스형에 맞게 설계된 메서드를 프로그램이 선택하도록 허용하기 때문이다.

 

그런데, 왜 정적 결합이 있는 걸까?

효율성과 개념 모델이라는 2가지 이유가 있기 때문이다.

 

정적 결합이 더 효율적이기 때문에 C++에서 그것이 디폴트로 설정되어 있다.

(메모리 사용이나 처리 시간 등의) 부담을 떠안지 않는 것이 C++의 기본 철학

가상 함수는 프로그램 설계상 꼭 필요한 경우에만 사용해야 한다.

 

가상이 아닌 함수로 만듦으로써 2가지가 이뤄지는데

1. 효율이 좋아지고

2. 이 함수가 다시 정의되면 안 된다는 의도를 들어냄

기초 클래스에 있는 메서드를 파생 클래스에서 다시 정의할 예정이라면,

그것을 가상 함수로 만들어야 한다는 것이다.

 

가상함수는 어떻게 동작하는가?

C++은 가상 함수들이 어떻게 동작해야 하는지를 규정하고 있다. 이것을 사용해서 구현하는 것은 프로그래머의 몫이다.

다만 가상 함수의 개념을 조금 더 잘 이해하고 사용한다면 더 올바른 사용으로 이뤄진다.

 

컴파일러가 가상 함수를 다루는 일반적인 방법은, 각각의 객체에 숨겨진 멤버를 하나씩 추가하는 것이다.

숨겨진 멤버는 함수의 주소들로 이루어진 배열을 지시하는 포인터를 저장하는 것인데

일반적으로 그 배열을 가상 함수 테이블(virtual function table)이라고 한다.

 

vtbl는 그 클래스의 객체들을 위해 선언된 가상 함수들의 주소가 저장되어 있다.

기초 클래스의 한 객체는 그 클래스를 위한 모든 가상 함수들의 주소로 이루어진 테이블을 지시하는 포인터를 가진다.

파생 클래스의 한 객체는 가상 함수들의 주소로 이루어진 별개의 테이블을 지시하는 포인터를 가진다.

파생 클래스가 가상 함수에 대해 새로운 정의를 제공한다면, vtbl은 새로 정의된 함수의 주소를 저장하게 된다.

 

파생 클래스가 가상 함수를 다시 정의하지 않으면, vtbl은 그 함수의 오리지널 버전의 주소를 저장한다.

파생 클래스가 새로운 함수를 정의하고 그 함수를 가상으로 선언한다면 그 주소가 vtbl에 추가된다.

 

가상 함수를 1개를 정의하든 10개를 정의하든 간에, 주소 멤버는 한 객체에 하나만 추가되며

정의하는 가상 함수의 개수에 따라 테이블의 크기만 변하게 된다.

 

가상 함수를 호출하면, 프로그램은 객체의 vtbl 주소가 저장되어 있다는 것을 알게 되고,

함수 주소들로 이루어진 해당 테이블에 접근하게 된다.

사용하는 함수가 클래스 선언에 정의된 첫 번 째 가상 함수라면, 프로그램은 그 배열에 있는 첫 번째 주소를 사용하고,

그 주소에 있는 함수를 실행시킨다.

 

정리하면, 가상 함수를 사용하면 메모리와 실행 속도 면에서 약간의 부담이 따르게 되는데

  • 각 객체의 크기가 주소 하나를 저장하는 데 필요한 양만큼 커진다.
  • 각각의 클래스에 대해, 컴파일러는 가상 함수들의 주소로 이루어진 하나의 테이블(배열)을 만든다.
  • 각각의 함수 호출에 대해, 실행할 함수의 주소를 얻기 위해 테이블에 접근하는 가외의 단계가 더 필요하다.

가상이 아닌 함수들은 가상 함수보다 조금 더 효율적이지만, 동적 결합을 제공하지 않는다.

class Scientist
{
	...
    char name[40];
    
 public:
 	virtual void show_name();
    virtual void show_all();
    ...
};

class Physicist : public Scientist
{
	...
    char field[40];
public:
	void show_all(); // 다시 정의된 함수
    virtual void show_field(); // 새로 정의된 함수
    ...
};

# Scientist 가상 함수 테이블을 가리키는 테이블 주소 2008
4064(show_name), 6400(show_all)

# Physicist 가상 함수 테이블을 가리키는 테이블 주소 2096
4064(show_name), 6820(show_all, 다시 정의된 함수), 7280(show_field)

Physicist adam(...);
Scientist * psd = &adam;

psc->show_all();

1. psc->vptr의 값을 얻는다.
2. 2096의 테이블로 이동한다.
3. 그 테이블에 있는 두 번째 함수의 주소(6820)을 얻는다.
4. 그 주소(6820)로 가서 거기에 있는 함수를 실행한다.

Scientist 가상 함수 테이블을 지시하는 숨겨진 포인터 멤버 vptr를 가짐

Physict 가상 함수 테이블을 지시하는 숨겨진 포인터 멤버 vptr를 가짐

 

가상 메서드에 대해 알아야 할 사항들

  • 기초 클래스에서 클래스 메서드를 선언할 때, virtual로 시작하면, 그 함수는 기초 클래스, 기초 클래스에 파생된 클래스, 파생 클래스에서 다시 파생된 클래스 등 모든 클래스에 대해 가상이 된다.
  • 객체에 대한 참조를 사용하여 또는 포인터를 사용하여 가상 메서드가 호출되면, 프로그램은 그 참조나 포인터형을 위해 정의된 메서드를 사용하지 않고, 객체형을 위해 정의된 메서드를 사용한다. (동적 결합)
  • 상속을 위해 기초 클래스로 사용할 클래스를 정의할 때, 파생 클래스에서 다시 정의해야 하는 클래스 메서드들은 가상 함수로 선언해야 한다.

# 생성자

생성자는 가상으로 선언할 수 없다.

파생 클래스 객체의 생성은 기초 클래스 생성자가 아니라 파생 클래스 생성자를 호출한다.

그러고 나서 파생 클래스 생성자가 기초 클래스 생성자를 호출한다.

 

# 파괴자

클래스가 기초 클래스로 사용된다면, 파괴자는 가상으로 선언해야 한다.

파생 클래스의 수명이 다했을 때, 파생 클래스의 파괴자가 호출되어 그 메모리를 해제하는 것이 매우 중요하기 때문이다.

만약에 디폴트 정적 결합이 적용된다면, 기초 클래스의 파괴자만 호출될 것이다.

하지만 가상 파괴자라면 파생 클래스의 파괴자도 호출된다. (파생 클래스에서 new를 사용했다면)

 

기초 클래스가 명시적 파괴자의 서비스를 요구하지 않더라도 디폴트 파괴자에 의존하면 안 된다.

아무 일도 하지 않는 가상 파괴자를 반드시 제공해 주자.

어떤 클래스가 가상 파괴자를 가지는 것은 에러가 아닌, 효율성의 문제다.

즉, 파괴자가 필요 없는 기초 클래스라 하더라도 가상 파괴자를 제공해야 한다.

 

# 프렌드

프렌드는 가상 함수가 될 수 없다.

멤버 함수만 가상 함수가 될 수 있는데, 프렌드는 클래스 멤버가 아니기 때문이다.

 

# 가상 함수를 다시 정의하지 않는다면

파생 클래스가 (가상이든 아니든) 함수를 다시 정의하지 않는다면, 파생 클래스는 그 함수의 기초 클래스 버전을 사용한다.

 

# 가상 함수를 다시 정의하면 메서드가 은닉된다.

어떤 함수를 파생 클래스에서 다시 정의하면, 동일한 함수 시그내처를 가지고 있는 기초 클래스 선언만 가리는 것이 아니라, 매개변수 시그내처와는 상관없이 같은 이름을 가진 모든 기초 클래스 메서드들을 가리게 된다.

 

기초 클래스에서도 virtual void print() const;

파생 클래스에서도 virtual void print() const;

기초 메서드
virtual Dwelling& build(int n);

파생 메서드
virtual Hovel& build(int n); // 동일한 함수 시그내처

상속된 메서드를 재정의할 경우에는 오리지널 원형과 정확히 일치시킬 필요가 있다.
이 규칙에 비교적 새로운 예외가 있다면, 리턴형이 기초 클래스에 대한 참조나 포인터인 경우
파생 클래스에 대한 참조나 포인터로 대체될 수 있다는 것이다.

리턴형이 클래스형과 병행하여 바뀌는 것을 허용하기 때문에, 
이 기능을 리턴형의 공변(covariance)이라고 한다.

기초 클래스 선언이 오버로딩되어 있다면, 파생 클래스에서 모든 기초 클래스 버전들을 재정의해야 한다.

// 기초
virtual void showperks(int a) const;
virtual void showperks(double x) const;
virtual void showperks() const;

// 파생
virtual void showperks(int a) const;
virtual void showperks(double x) const;
virtual void showperks() const;

한 가지 버전만 재정의하면, 나머지 두 버전은 가려져 파생 클래스 객체가 그들을 사용할 수 없게 된다.
변경이 필요 없다면, 재정의는 단순히 기초 클래스 버전을 호출할 것

 

접근 제어: protected

가능하다면 protected 접근 제어보다는 private 접근 제어를 사용하여 클래스 데이터 멤버들에 접근해야 한다.

파생 클래스가 기초 클래스 데이터에 접근하게 하려면 기초 클래스 메서드를 사용해야 한다.

 

추상화 기초 클래스 (abstract base class = ABC)

길고 길게 왔다.

사실 지금까지는 복잡도가 낮은 간단한 상속과 다형 상속에 대해서 알았다면, 이제는 복잡도가 더 높아진 추상화 기초 클래스에 대해서 알아보자.

 

상속 관련해서 is-a 규칙을 적용하는 것이 생각처럼 쉽지 않을 때가 있는데,

공통적인 것들을 추출해서 그것들을 추상화 기초 클래스에 넣을 수 있다.

 

다만 추상화 기초 클래스는 필요한 데이터 멤버를 가지고 있지 않아서, 공통 메서드를 구현할 수 없는데

C++는 구현되지 않는 이러한 함수를 위해 순수 가상 함수(pure virtual function)를 제공하고 있다.

 

순수 가상 함수는 함수 선언 뒤에 = 0을 가지게 된다.

클래스 선언에 순수 가상 함수가 들어있다면, 그 클래스는 객체를 생성할 수 없다.

어떤 클래스가 진짜 추상화 기초 클래스가 되려면, 적어도 하나의 순수 가상 함수를 가져야 한다.

C++는 순수 가상 함수가 정의를 가지는 것을 허용한다.

void Move(int nx, int ny) = 0;

ABC는 적어도 하나의 순수 가상 함수를 사용하는 인터페이스를 서술한다.

ABC로부터 파생된 클래스들은 일반 가상 함수를 사용하여, 그 특별한 파생 클래스의 속성을 가지고 그 인터페이스를 구현한다.

 

아래 코드는 추상화에 관한 코드다.

#ifndef ACCTABC_H_
#define ACCTABC_H_
#include <iostream>
#include <string>

// 추상화 기초 클래스
class AcctABC
{
private:
	std::string fullName;
	long acctNum;
	double balance;

protected:
	struct Formatting
	{
		std::ios_base::fmtflags flag;
		std::streamsize pr;
	};
	const std::string& FullName() const { return fullName; }
	long AcctNum() const { return acctNum; }
	Formatting SetFormat() const;
	void Restore(Formatting& f) const;

public:
	AcctABC(const std::string& s = "Nullbody", long an = -1, double bal = 0.0);
	void Deposit(double amt);
	virtual void Withdraw(double amt) = 0; // 순수 가상 함수
	double Balance() const { return balance; }
	virtual void ViewAcct() const = 0; // 순수 가상 함수
	virtual ~AcctABC() {}
};

class Brass : public AcctABC
{
public:
	Brass(const std::string& s = "Nullbody", long an = -1, double bal = 0.0) : AcctABC(s, an, bal) {}
	virtual void Withdraw(double amt);
	virtual void ViewAcct() const;
	virtual ~Brass() {}
};

class BrassPlus : public AcctABC
{
private:
	double maxLoan;
	double rate;
	double owesBank;

public:
	BrassPlus(const std::string& s = "Nullbody", long an = -1, double bal = 0.0, double m1 = 500, double r = 0.10);
	BrassPlus(const Brass& ba, double m1 = 500, double r = 0.10);

	virtual void ViewAcct() const;
	virtual void Withdraw(double amt);
	void ResetMax(double m) { maxLoan = m; }
	void ResetRate(double r) { rate = r; }
	void ResetOwes() { owesBank = 0; }
};
#endif
#include <iostream>
#include <string>
#include "acctabc.h"
using std::cout;
using std::ios_base;
using std::endl;
using std::string;

AcctABC::AcctABC(const string& s, long an, double bal)
{
	fullName = s;
	acctNum = an;
	balance = bal;
}

void AcctABC::Deposit(double amt)
{
	if (amt < 0)
		cout << "마이너스 입금은 허용되지 않습니다.\n그래서 입금이 취소되었습니다.\n";
	else
		balance += amt;
}

void AcctABC::Withdraw(double amt)
{
	balance -= amt;
}

AcctABC::Formatting AcctABC::SetFormat() const
{
	Formatting f;
	f.flag = cout.setf(ios_base::fixed, ios_base::floatfield);
	f.pr = cout.precision(2);
	return f;
}

void AcctABC::Restore(Formatting& f) const
{
	cout.setf(f.flag, ios_base::floatfield);
	cout.precision(f.pr);
}

void Brass::Withdraw(double amt)
{
	if (amt < 0)
		cout << "마이너스 입금은 허용되지 않습니다.\n그래서 인출이 취소되었습니다.\n";
	else if (amt <= Balance())
		AcctABC::Withdraw(amt);
	else
		cout << "인출을 요구한 금액 $" << amt << "가 현재 잔액을 초과합니다.\n그래서 인출이 취소되었습니다.\n";
}

void Brass::ViewAcct() const
{
	Formatting f = SetFormat();
	cout << "Brass 고객: " << FullName() << endl;
	cout << "계좌 번호: " << AcctNum() << endl;
	cout << "현재 잔액: $" << Balance() << endl;
	Restore(f);
}

BrassPlus::BrassPlus(const string& s, long an, double bal, double m1, double r) : AcctABC(s, an, bal)
{
	maxLoan = m1;
	owesBank = 0.0;
	rate = r;
}

BrassPlus::BrassPlus(const Brass& ba, double m1, double r) : AcctABC(ba) // 암시적 복사 생성자를 사용된다.
{
	maxLoan = m1;
	owesBank = 0.0;
	rate = r;
}

void BrassPlus::ViewAcct() const
{
	Formatting f = SetFormat();
	cout << "BrassPlus 고객: " << FullName() << endl;
	cout << "계좌 번호: " << AcctNum() << endl;
	cout << "현재 잔액: $" << Balance() << endl;
	cout << "당좌 대월 한도: $" << maxLoan << endl;
	cout << "상환할 원리금: $" << owesBank << endl;
	cout.precision(3);
	cout << "당좌 대월 이자율: " << 100 * rate << "%\n";
	Restore(f);
}

void BrassPlus::Withdraw(double amt)
{
	Formatting f = SetFormat();

	double bal = Balance();
	if (amt <= bal)
		AcctABC::Withdraw(amt);
	else if (amt <= bal + maxLoan - owesBank)
	{
		double advance = amt - bal;
		owesBank += advance * (1.0 + rate);
		cout << "당좌 대월 금액: $" << advance << endl;
		cout << "당좌 대월 이자: $" << advance * rate << endl;
		Deposit(advance);
		AcctABC::Withdraw(amt);
	}
	else
		cout << "당좌 대월 한도가 초과되어 거래가 취소되었습니다.\n";
	Restore(f);
}

 

추상화 기초 클래스의 사용은,

컴포넌트 설계자가 인터페이스 약정을 만드는 것을 허용한다.

이 인터페이스 약정은 ABC로부터 파생되는 모든 컴포넌트들이 최소한 ABC가 규정하는 공통 기능을 지원한다는 것을 보장한다.

 

상속과 동적 메모리 대입

2가지 경우에 대해서 조금 더 자세하게 보면,

파생 클래스가 new를 사용하지 않는다.
class baseDMA
{
private:
	char * label;
    int rating;
    
public:
    baseDMA(const char * l = "null", int r = 0);
    baseDMA(const baseDMA & rs);
    virtual ~baseDMA();
    baseDMA & operator=(const baseDMA & rs);
};

생성자가 new를 사용할 때,

요구되는 특별한 메서드들은 파괴자 / 복사 생성자 / 오버로딩 대입 연산자를 가지고 있다.

class lacksDMA : public baseDMA
{
private:
	char color[40];
public:
	...
};

lacksDMA 클래스를 위해 명시적 파괴자, 복사 생성자, 대입 연산자를 정의해야 할까? No

lacksDMA 멤버들은 어떤 특별한 동작도 요구하지 않는다고 가정했기 때문에, 디폴트 파괴자만 있으면 충분하다.

 

디폴트 복사 생성자는 멤버별 복사를 하는데, 멤버별 복사는 동적 메모리 대입에서는 사용할 수 없다.

그렇지만 lacksDMA 멤버에 대해서는 멤버별 복사를 사용할 수 있다.

멤버별 복사는 데이터형에 맞게 정의된 복사 형식을 사용한다.

그러나 클래스 멤버 또는 상속받은 클래스 성분을 복사하는 것은, 그 클래스의 복사 생성자를 통하여 이뤄지므로

lacksDMA 클래스의 디폴트 복사 생성자는 lacksDMA 객체의 baseDMA 성분을 복사하기 위해

명시적인 baseDMA 복사 생성자를 사용한다.

lacksDMA 클래스의 복사 생성자는 새로운 lacksDMA 멤버에 대해 사용할 수 있고, 상속받은 baseDMA 객체에 대해서도 사용할 수 있다.

 

대입에 대해서도 동일한 논리가 적용된다.

마찬가지로 디폴트 대입 연산자만 있으면 충분하다.

 

흔히 자주 쓰이는 string 객체는 표준 string 클래스가 동적 메모리 대입을 사용한다.

이것이 문제가 일어나지 않는 이유는 대입 연산자가 string 대입 연산자를 사용하여 객체의 ~ 멤버를 대입하고,

string을 쓰는 클래스는 자동적으로 string 파괴자를 호출하게 되기 때문이다.

 

파생 클래스가 new를 사용한다.
class hasDMA : public baseDMA
{
private:
	char * style; // 생성자에서 new 사용한다면
public:
	...
};

이 경우에서는 파생 클래스를 위한 명시적 파괴자, 복사 생성자, 대입 연산자를 당연히 정의해야 한다.

 

파생 클래스의 파괴자는 기초 클래스 파괴자를 자동으로 호출한다.

hasDMA 파괴자는 style 포인터에 의해 관리되는 메모리를 해제해야 누수가 발생하지 않는다.

그리고 hasDMA 파괴자는 label 포인터에 의해 관리되는 메모리를 해제하기 위해서 baseDMA 파괴자에 의존할 수 있다.

baseDMA::~baseDMA() // baseDMA 관련 처리
{
	delete[] label;
}

hasDMA::~hasDMA()   // hasDMA 관련 처리
{
	delete[] style;
}

 

복사 생성자에 대해서는

strlen() 사용해서 string의 필요한 길이를 알아내고 충분한 메모리를 대입한다.

baseDMA::baseDMA(const baseDMA& rs)
{
    label = new char[std::strlen(rs.label) + 1]
    std::strcpy(label, rs.label);
    rating = rs.rating;
}

hasDMA 복사 생성자만이 hasDMA 데이터에 접근할 수 있어서,

hasDMA 생성자는 그 데이터의 baseDMA 성분을 다루기 위해서는 baseDMA 복사 생성자를 호출해야 한다.

hasDMA::hasDMA(const hasDMA& hs)
	: baseDMA(hs) // 기초 클래스 복사 생성자를 호출한다.
{
	style = new char[std::strlen(hs.style) + 1];
	std::strcpy(style, hs.style);
}

멤버 초기자 리스트가 baseDMA 생성자에 hasDMA 참조를 전달하는 것을 확인할 수 있다.

그런데 hasDMA 참조형을 매개변수로 사용하는 baseDMA 생성자가 없다면??

 

문제없음.

baseDMA 복사 생성자가 baseDMA 참조형의 매개변수를 사용하고 있고,

기초 클래스 참조가 파생형을 참조할 수 있기 때문에

그래서 baseDMA 복사 생성자가 hasDMA 매개변수의 baseDMA 부분을 사용해서 새 객체의 baseDMA 부분을 생성하게 된다.

 

대입 연산자의 고려도 중요한데

대입할 때에는 자기 자신인지 체크하고,

기존에 메모리를 점유하고 있던 부분들은 해제하고 다시 만들어서 복사해야 한다.

baseDMA& baseDMA::operator=(const baseDMA& rs)
{
	if (this == &rs)
		return *this;

	delete[] label;
	label = new char[std::strlen(rs.label) + 1];
	std::strcpy(label, rs.label);
	rating = rs.rating;
	return *this;
}
hasDMA& hasDMA::operator=(const hasDMA& rs)
{
	if (this == &rs)
		return *this;
	// 파생 클래스의 명시적 대입 연산자는
	// 상속받은 baseDMA 기초 클래스 객체를 위한 대입을 처리할 수 있어야 한다.
	baseDMA::operator=(rs); // 기초 클래스 부분을 복사한다.
	delete[] style;
	style = new char[std::strlen(rs.style) + 1];
	std::strcpy(style, rs.style);
	return *this;
}

hasDMA 역시 동적 메모리 대입을 사용하기 때문에, 명시적인 대입 연산자를 구현해야 한다.

하지만, hasDMA 메서드이므로 baseDMA에 접근할 수 없는데

파생 클래스의 명시적 대입 연산자는, 상속받은 baseDMA 기초 클래스 객체를 위한 대입을 처리할 수 있어야 한다.

baseDMA::operator=(rs);

기초 클래스 부분을 복사한다.

 

동적 메모리 대입과 프렌드를 사용하는 부분

파생 클래스들이 기초 클래스의 프렌드에 접근할 수 있는 테크닉

강제 데이터형 변환을 사용해서 원형을 일치시키고 올바른 함수를 선택할 수 있도록 하는 것이다.

baseDMA 프렌드 함수

friend std::ostream& operator<<(std::ostream& os, const baseDMA& rs); // 선언부

std::ostream& operator<<(std::ostream& os, const baseDMA& rs) // 구현부
{
	os << "상표: " << rs.label << std::endl;
	os << "등급: " << rs.rating << std::endl;
	return os;
}


hasDMA 프렌드 함수
friend std::ostream& operator<<(std::ostream& os, const hasDMA& rs); // 선언부

std::ostream& operator<<(std::ostream& os, const hasDMA& rs) // 구현부
{
	// hasDMA 클래스의 프랜드이기 때문에 style 접근 ok
	// baseDMA 클래스에 대해서는 프랜드가 아니기 때문에 접근할 수 없으나
	// 강제 데이터형 변환을 사용해서 원형 일치로 프랜드 함수를 호출해서 접근할 수 있다.
	// operator<<(ostream&, const baseDMA&)
	os << (const baseDMA&)rs;
	os << "스타일: " << rs.style << std::endl;
	return os;
}

 

어느 정도 상속에 대한 개념이 잡힌 것 같다.

꾸준한 반복으로 익숙해져 보자.

'CS > C++' 카테고리의 다른 글

클래스 설계  (0) 2024.07.28
접두어 방식과 접미어 방식의 차이  (1) 2023.03.05

개발을 하다 보면 정말 많이 사용하는 것이 바로 문자열입니다. 보통 문자열을 표현할 때에는 정말 순수하게 문자열만 표현할 수도 있지만, 간혹 숫자와 같이 작성하거나 다른 특수 기호와 함께 사용되어야 할 때에도 많이 존재하는데요.

C#에서는 조금 더 편리한 기능을 제공하는 보간 문자열에 대해서 알아보고자 합니다.

 

보간 문자열?

이름이 되게 어색하게 들릴 수 있지만 C# 6.0에 도입된 기능입니다.

제일 좋은 것은 바로 코드 가독성이 대폭 향상됩니다. 코드 가독성이 향상되었다는 것은 미연에 개발자의 실수를 줄이고 조금 더 복잡한 문제에 집중할 수 있게 된다는 뜻도 있습니다.

 

기존에 널리 사용되었던 string.Format() 또한 문자열 변환 과정을 잘 수행하지만, 생성된 문자열을 직접 출력해보고 올바른 형태인지를 눈으로 직접 확인하기 전까지는 코드를 제대로 작성했는지를 쉽게 판별하기가 어렵습니다.

 string format = "{0} money";
 Console.WriteLine(string.Format(format, 10));

포맷 문자열과 인자 리스트를 분리해서 전달하는 구조라 정확하게 포맷 문자열에 나타낸 인자의 개수와 실제로 전달되는 인자의 개수가 정확히 일치하는지 여부는 따로 확인해주지 않습니다. 자칫 실수로 이어져 필요한 인자를 누락하면 런타임에서 예외가 발생합니다.

 

저희 회사 프로젝트에서도 이러한 포맷팅 형식을 많이 사용하는데요.

간단한 포맷팅 형식이나 구조라면 상관없지만 {0} 와 같은 인자를 넘기는 횟수가 4개 이상 넘어가게 되면 이게 어떤 거고 그런지 파악하는 시간도 길어질 뿐더러 코드도 길어져 확인이 조금 더뎌지는 경우도 있습니다. ㅎㅎ(나쁘다는 것은 아닙니다.)

 

보간 문자열의 사용방법은 문자열 앞에 $를 붙이면 끝납니다. 그리고 문자열로 변경할 표현식은 { } 중괄호 내에 두면 됩니다.

Console.WriteLine($"{10} money");

엄청난 코드 가독성을 보여줍니다. 바로 문자열에 그 숫자가 넣어져 보여질 것을 인식할 만큼 빠르게 대입되는 것이 눈에 보이며, 결과 예측도 쉽습니다.

 

다만 주의할 점이 있습니다.

Console.WriteLine($"The value of pi is {Math.PI}");

문자열 보간 기능을 사용하더라도 실제 C# 컴파일러는 param을 이용해서 object 배열을 전달하는 기존 포맷팅 함수를 호출하도록 코드를 생성하게 됩니다.

Math.PI는 double 이므로 값 타입인데요. 이를 object 타입으로 변경하려면 박싱을 수행해야 합니다. 이러한 이유로 이 같은 코드를 너무 자주 사용한다거나 루프 내에서 사용하게 된다면 성능에 좋지 않은 영향을 미칠 수 있게 됩니다.

 

전달할 인자를 사전에 문자열로 변경한다면? 값 타입이 박싱되는 것을 회피할 수 있습니다.

Console.WriteLine($"The value of pi is {Math.PI.ToString()}"); // 3.141592653589793

기본적으로 제공되는 ToString() 메서드가 반환하는 문자열이 썩 유용하지 않은 경우가 있는데 이 경우 문자열을 어떻게 포맷팅 할 것인지를 표현하기 위해서 추가적으로 인자를 전달할 수 있습니다.

Console.WriteLine($"The value of pi is {Math.PI.ToString("F2")}"); // 3.14

 

보간 문자열의 표현식은 중첩해서 사용할 수 있습니다. { } 문자 사이의 모든 구문은 C# 코드의 일부인 동시에 표현식으로 간주됩니다.

Console.WriteLine($"The customer's name is {c?.Name ?? "Name is missing"}");

C가 NUll인지 Null 조건 연산자를 통해서 확인하고, 해당 Name 프로퍼티가 null 이면 오른쪽 구문을 수행하는 Null 병합 연산자까지 함께 사용한 코드가 위 코드입니다.

 

이처럼 문자열 보간 기능은 실수를 줄일 수 있고 더욱 강력할 뿐 아니라 활용도 또한 매우 높은 기술입니다.

적극 사용합시다!

 

최근 저도 개발하면서 문자열 관련 코드는 보간 문자열로 대체해서 사용하고 있습니다. ㅎㅎ

'CS > Effective C#' 카테고리의 다른 글

캐스트보다는 is, as가 좋다  (0) 2023.05.06
const보다는 readonly가 좋다.  (0) 2023.04.23
지역변수는 var를 사용하는 것이 낫다.  (0) 2023.04.23

캐스트의 경우에는 스타크래프트로 따지면, 드랍쉽에는 여러 유닛을 태울 수 있는데요. 여러 유닛의 각 특징들의 메서드(행동)를 가져오려고 할 때 그 유닛으로 캐스팅을 해야 그 유닛의 고유의 능력이나 행동을 가져올 수 있습니다.

그럴 때 캐스트가 필요하게 되는데요.

캐스트에 대해서 어떻게 하면 더 효율적으로 사용할 수 있는지에 대해서 알아봅시다.

 

C#은 정적 타이핑을 수행하는 언어입니다.

그래서 보통 판단은 정적으로 하려고 하는데요. 위와 같은 상황에서 런타임에서(게임 실행 중)에 반드시 타입을 확인해야할 필요가 있는 경우가 있습니다.

 

C#에서는 2가지 캐스트를 지원합니다.

1. as 연산자를 사용하는 방법 (더 방어적으로 사용하는 is 연산자로 형변환이 가능한지를 확인한 후에 실제 형변환 진행)

2. 일반적으로 알고 있는 컴파일러의 캐스트 연산자 구문을 사용하는 방법

 

형변환을 수행하는 경우에는 캐스팅을 사용하기보다는 as 연산자를 추천합니다.

- 안전한다.

- 런타임에 더 효율적으로 동작한다.

 

다만 as 나 is 연산자는 객체의 타입이 변환하려는 타입과 정확히 일치할 경우에만 형변환이 성공적으로 수행됩니다.

일반적으로는 형변환 과정에서 새로운 객체가 생성되는 경우는 없지만,

예외적으로 as 연산자를 이용하여 박싱된 값 타입의 객체를 nullable 값 타입의 객체로 변환하는 경우에 새로운 객체가 생성됩니다.

object o = Factory.GetValue();
var i = o as int?;
if (i != null)
    Console.WriteLine(i.Value);

위의 경우에는 as 연산자를 사용할 때 int는 값 타입이고 null이 될 수 없어서 일반적으로는 int로 형변환이 불가능하지만,

as 연산자는 그대로 사용하면서 nullable 타입으로 형변환을 수행한 후에 그 값이 null인지 판단으로 확인하는 방법도 있다.

 

as 연잔자를 사용해서 캐스팅 방법은 다음과 같다.

 object o = Factory.GetObject();

 MyType t = o as MyType;

 if (t != null)
 {
     // MyType 객체 사용
 }
 else
 {
     // 실패
 }
as 연산자와 캐스팅의 가장 큰 차이

사용자 정의 형변환을 어떻게 다루는가 하는 점입니다.

 

as나 is연산자는 런타임에 객체의 타입을 확인하고 필요에 따라서 박싱을 수행하는 것을 제외하고는 어떠한 작업도 수행하지 않습니다. 임의의 객체를 다른 타입으로 형변환하려면 이 객체는 지정한 타입이거나 혹은 지정한 타입을 상속한 타입이어야만 하고 그 외에는 null로 지정됩니다.

 

캐스팅의 경우 객체를 지정한 타입으로 변환하기 위해서 형변환 연산자가 개입될 수 있습니다.

대표적인 형변환 연산자가 바로 숫자 타입에 대한 형변환 연산자라면 (long 타입을 short 타입으로 캐스팅하면 일부 정보를 잃을 수 있다는 것)이 문제가 됩니다.

물론 사용자의 의도일 수 있겠지만 일반적으로 data의 손실이 되도록 코드를 짜는 것은 좋지 않습니다.

class SecondType
{
    private MyType value;

    public static implicit operator MyType(SecondType t)
    {
        return t.value;
    }
}

class MainApp
{
    static void Main()
    {
        object o = Factory.GetObject();

        // o는 SecondType이라고 가정하면
        // 첫 번째 방법
        MyType t = o as MyType;

        if (t != null)
        {
            // MyType 타입의 t 객체 사용
        }

        // 두 번째 방법
        try
        {
            MyType t2;
            t2 = (MyType)o;

            // MyType 타입의 t2 객체 사용
        }
        catch (InvalidCastException e)
        {
            // 형변환 오류 보고
        }
    }
}

as 연산자와 캐스팅의 방법은 모두 실패합니다.

캐스팅 방법을 사용하면 사용자 정의 형변환 연산자가 사용된다고 했었으나,,

컴파일러는 런타임에 객체가 어떤 타입일지를 예측하지 못..(정적 타이핑 이므로)

컴파일러는 단순히 컴파일 타임에 객체가 어떤 타입으로 선언됐는지만 추적하기 때문에 모두 실패하게 됩니다.

 

컴파일러는 객체 o가 런타임에 어떤 타입인지를 알 방법이 없습니다.

컴파일러는 객체 o가 object 타입이라고 생각하고 object 타입을 MyType으로 형변환할 수 있는 연산자가 정의됐는지만을 확인합니다. 이러한 형변환 연산자를 정의하지 않았기 때문에 컴파일러는 이제 o 객체가 MyType 형식인지를 확인하는 코드만 생성합니다.

런타임에 o는 SecondType 형식의 객체이므로 형변환에 실패하게 됩니다.

컴파일러는 런타임에 o가 어떤 타입일 지를 예측하고 이를 기반으로 MyType으로 형변환이 가능한지를 고려하지는 않습니다.

 

사용자 정의 형변환 연산자는 객체의 런타임 타입이 아닌 컴파일타임 타입에 맞춰 수행된다는 점에 다시 한번 유의합니다.

런타임에 o 객체를 MyType으로 변환할 수 있는지는 중요하지 않고 이에 대해서 알지도 못하며 고려하지도 않습니다.

다만 사용자 정의 형변환 연산자가 정의되었다면?

어떤 타입으로 선언되었느냐에 따라 다르게 동작할 수 있습니다.

 

캐스팅보다 as 연산자를 사용한다라면 어떤 타입으로 선언되었든 간에 항상 동일한 결과를 반환해줍니다.

t = o as MyType;

설사 사용자 정의 형변환 연산자가 정의됐다 하더라도 o가 MyType이나 MyType을 상속한 타입이 아니라면 컴파일 오류를 발생합니다.

 

foreach

자주 사용하는 foreach 루프는 제너릭 타입이 아닌 IEnumerable 인터페이스를 사용합니다.

class MainApp
{
    // version 1
    public void UseCollection(IEnumerable theCollection)
    {
        foreach (MyType t in theCollection)
            t.DoSuff();
    }
    
    // version 2
    public void UseCollection2(IEnumerable theCollection)
    {
        IEnumerator it = theCollection.GetEnumerator();
        while (it.MoveNext())
        {
            MyType t = (MyType)it.Current;
            t.DoSuff();
        }
    }
}

foreach 문은 캐스팅을 통해서 루프에서 사용되는 타입으로 객체를 형변환합니다.

version 1이 흔히 사용하는 foreach 문이고, 아래 version 2는 위의 foreach 문이 생성하는 코드입니다.

 

foreach 문은 값 타입과 참조 타입 모두에 대해서 형변환을 지원해야 하는데 이때 캐스팅을 사용하면 대상 타입을 구분할 필요가 없어지게 됩니다. 하지만 이로 인해 InvalidCastException이 발생할 가능성이 존재합니다.

 

foreach 문장은 컬렉션 내부의 객체가 런타임에 어떤 타입인지, 그 타입을 루프 변수 타입으로 형변환 가능한지 등을 확인하지 않습니다. IEnumerator.Current의 반환형인 System.Object 타입으로 형변환 가능한지와, Object 타입의 객체를 다시 루프 타입 변수로 형변환 가능한지만 확인합니다.

 

특별히 객체의 타입이 정확히 MyType인 경우에만 동작하는 함수를 만들고자 한다면 더 정밀한 타입 비교가 필요한데,

.NET 기본 클래스 라이브러리에는 시퀀스 내의 개별 요소들을 특정 타입으로 형변환하는 Enumerable.Cast<T>() 메서드가 존재합니다.

class MainApp
{
    static void Main(string[] args)
    {
        IEnumerable collection = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

        var small = from int item in collection
                    where item < 5
                    select item;

        var small2 = collection.Cast<int>().Where(item => item < 5).Select(n => n);
    }
}

여기서 Cast<T> 메서드는 as 연산자 대신 캐스트 연산을 수행하는데, as 연산자를 사용하면 형변환하려는 타입에 제한이 생기기 때문에 Cast<T> 메서드를 구현하는 대신 캐스트 연산을 사용하는 단일 Cast<T> 메서드만 작성했다고 합니다.

 

가능하면 형변환은 피하자.

사용자의 의도를 명확히 표현할 수 있는 is나 as 연산자를 사용하는 것이 좋습니다.

is나 as 연산자는 거의 예상대로 동작하며 대상 객체를 올바르게 형변환할 수 있을 경우에만 성공하고 나머지는 실패합니다.

 

캐스트 연산보다는 is나 as를 사용하는 것이 의도하지 않은 부작용이나 예상치 못한 문제를 피할 수 있는 좋은 방법이 됩니다.

간혹 성능을 위해서 상수형을 작성하게 될 때 엄청난 고민을 하게 됩니다.

이때 누구는 const를 쓰고, 또 누구는 readonly를 쓰고 있습니다. 어떨 때 이걸 쓰고, 또 어떨 때 이걸 쓰는지 코드를 작성하다 보면 고민이 깊어져 2시간가량을 먹었던 기억이 있습니다.

그 답 오늘 해결해봅시다!

 

C#은 컴파일타임 상수와 런타임 상수 2 유형의 상수를 가집니다.

컴파일타임 상수는 const를 의미하고, 런타임 상수는 readonly를 의미합니다.

// 컴파일타임 상수
public cosnt int Millennium = 2000;

// 런타임 상수
public static readonly int ThisYear = 2004;

컴파일타임 상수가 약간 더 빠르긴 하지만, 런타임 상수에 비해서 유연성이 떨어집니다.

결론적으로 말하자면,

컴파일타임 상수는 성능이 매우 중요하고 상수의 값이 절대로 바뀌지 않는 경우에만 제한적으로 사용하고,

나머지는 런타임 상수를 사용하는 것을 추천합니다.

 

컴파일타임 상수는 메서드 내에서 선언이 가능, 런타임 상수는 메서드 내에서 선언이 불가능

서로 다르게 동작하는 이유는 값에 접근하는 방식이 서로 다릅니다.

컴파일타임 상수는 컴파일타임에 변수가 값으로 대체됩니다.

// 사용자가 작성한 코드
if (myDateTime.Year == Millenium)

// IL 코드로 컴파일되면 아래와 같다.
if (myDateTime.Year == 2000)

반면에 런타임 상수런타임에 값이 평가됩니다.

상수처럼 값으로 대체되지 않고 상수에 대한 참조로 컴파일됩니다. (한 번 살펴보고 그 값으로 컴파일된다는 뜻)

 

이러한 차이로 인해서

컴파일 상수는 내장된 숫자형, enum, 문자열, null에 대해서만 사용이 가능합니다.

오히려 그 외의 값을 사용하려고 할 때에는 컴파일 오류가 발생합니다.

private const DateTime localTime = new DateTime(2023, 4, 23, 0, 0, 0);

런타임 상수는 생성자에서 초기화될 수 있으며 그 이후에는 수정될 수 없습니다.

런타임은 컴파일 상수보다 아까 유연하게 사용될 수 있다고 했습니다.

해당 런타임 상수는 어떤 타입과도 함께 사용이 가능하며, 멤버 초기화 구문뿐 아니라 생성자를 통해서도 초기화가 가능하다는 점입니다. (서로 같은 클래스의 인스턴스지만 내부적으로는 생성자에 의해 서로 다른 값을 가지는 장점도)

 

클래스 내에서 런타임 상수를 정의하는 경우라면 동일 클래스의 인스턴스라 하더라도 인스턴스 별로 서로 다른 값을 가질 수 있습니다. 반면에 컴파일타임 상수는 정의에 따라 정적 상수이므로 모든 인스턴스가 동일한 값을 가질 수밖에 없습니다.

 

가장 중요한 차이

상수의 값이 런타임에 평가된다는 점입니다.

런타임 상수를 참조하는 코드를 컴파일하면 컴파일타임 상수처럼 코드를 값으로 대체하지 않고, readonly 변수에 대한 참조 코드를 생성합니다.

 

응용프로그램을 유지보수할 때 상당한 영향을 미치는데요.

컴파일타임 상수는 다른 어셈블리의 참조 여부와 상관없이 항상 숫자나 문자열 등을 직접 사용한 것과 완전히 동일한 IL 코드를 생성합니다.

물론 성능상 좋겠지만 응용프로그램 전체를 리빌드(rebuild) 하지 않고 수정된 부분만 빌드(build)하면 오히려 예상했던 것과 다르게 이전에 빌드했는 결과가 나올 수 있다는 것입니다.

 

응용프로그램의 크기가 작으면 상관없겠지만, 보통은 리빌드 하는데 30 ~ 1시간 (많은 시간)이 소요되기 때문에 내가 수정한 영역만 다시 빌드하는 Build를 하기 마련입니다. 그런데 빌드만 하기에는 컴파일타임 상수는 수정된 것으로 반영되지 않는다는 것이죠. 그래서 해당 상수를 참조하는 모든 코드를 반드시 재컴파일 해야 합니다.

 

반대로 런타임 상수는 이러한 대응에 유연합니다. readonly 변수에 대한 참조 코드를 생성했기 때문에 컴파일 시에 참조 코드가 생성되고 런타임이 비로소 그 값을 평가하게 됩니다.

이렇게만 보면 readonly가 최고?!

readonly 대신 const를 사용했을 때 얻을 수 있는 장점은 성능이 빠릅니다.

코드를 상수값으로 대체하기 때문에 readonly 변수를 통해서 값을 참조하는 것보다는 당연히 빠를 수밖에 없습니다.

하지만,, 성능 개선 효과가 크지 않고 유연성을 해치기 때문에 사용자의 판단이 무엇보다 중요합니다.

 

특성 매개변수(attribute), swich/case 문의 레이블, enum 정의 시 사용하는 상수 등은 컴파일 시에 사용돼야 하므로 반드시 const 통해서 초기화하고 그 외에는 readonly를 사용하는 것이 좋습니다.

최근에 인턴으로 들어오신 팀원이 지난 주에 직군 면접을 봤었습니다.

직군 면접에 대해서 이것저것 얘기하다 보니 생각난 것이 있습니다.

바로 Effective C#인데요.

사수님께서 입사 후 제일 먼저 사주신 책입니다.

예전에 직군 면접 보기 전에 한번 정독했던 기억도 나고, 블로그에 정리하면서 내실을 다지는 것이 어떨까하여 시작합니다.

 

가장 흥미로우면서도 편리한 var 에 대해서 설명하는 단원이네요.

 

득 보다 실이 많다.

정확한 반환 타입을 알지 못한 채 올바르지 않은 타입을 명시적으로 지정하게 되면 오히려 손해라는 뜻입니다.

극단적으로 값 타입을 object 형으로 받아버리는 것도 마찬가지죠.

 

버그가 발생하여 유지보수를 하려고 할 때에 많은 코드 분석을 하게 됩니다.

이럴 때 너무 긴 코드는 오히려 집중도를 떨어트리기도 합니다. 이때는 var를 사용해서 너무 긴 타입의 형은 간단한 var 형식으로 처리할 수 있습니다.

// 1
Dictionary<int, MainQuest> mainQuestDic = new Dictionary<int, MainQuest>();
// 2
var mainQuestDic = new Dictionary<int, MainQuest>();

 주렁 주렁 타입이 긴 변수명은 생각을 해야 하는 프로그래머가 작성에만 집중하게 된다는 단점이 있습니다.

 

C#에서는 특정 변수를 var로 선언하게 되면, 동적으로 수행되는 것이 아니라 할당 연산자 오른쪽의 타입을 확인하고

왼쪽 변수의 타입을 결정하게 됩니다.

컴파일러는 변수의 타입을 명시적으로 알려주지 않더라도 개발자를 대신해서 올바른 타입으로 추론합니다.

마치 CPP의 auto 랑 비슷하네요.

 

Primitive type 은 var를 함께 사용할 때 주의가 필요합니다.

내장된 숫자 타입을 var랑 함께 사용하는 것은 문제가 되진 않으나 예상했던 결과와 다르게 동작할 수 있습니다.

var total = 100 * GetMagicNumber() / 6;

// GetMagicNumber() 가 어떤 타입의 값을 반환하는 지에 따라 데이터가 잘릴 수도 있다.

개발자는 컴파일러 처럼 내부적으로 이뤄지는 타입 추론과 암시적 변환 과정을 쉽사리 명확하게 이해하기가 어렵습니다. 간혹 휴먼 에러가 발생합니다.

명확한 계산(= 의도한 계산)에 대해서는 명시적으로 선언하는 것이 좋습니다.

 

오잉 그러면 결론?

var를 이럴 때 사용해보자.

- 모호함을 불러일으킬 가능성이 있는 타입은 명시적으로 선언하는 것이 좋다.

- Primitive type에 대해서는 가능하면 명시적으로 선언하여 실수를 줄이자.

그 외에는 var를 사용하는 것이 베스트다.

'CS > Effective C#' 카테고리의 다른 글

string.Format()을 보간 문자열로 대체하라  (0) 2023.05.06
캐스트보다는 is, as가 좋다  (0) 2023.05.06
const보다는 readonly가 좋다.  (0) 2023.04.23
어떤게 더 좋을까?
// 접미어
for (int i = 0; i < n; i++)
{
	...
}

// 접두어
for (int i = 0; i < n; ++i)
{
	...
}

 

접미어 방식이나 접두어 방식 모두 논리적으로는 아래 블록으로 들어오게 되면 i 자체는 논리적으로 모두 +1씩 증가하게 됩니다.

 

접미어 방식과 접두어 방식의 선택이 프로그램의 행동에 영향을 주지 않더라도, 이런 선택이 때때로는 실행 속도에 작게 나마 영향을 준다고 합니다.

 

접미어 방식은

1. 값의 복사본을 만든다.

2. 복사본의 값을 증가시킨다.

3. 복사본을 리턴한다.

 

접두어 방식은

1. 값을 증가시킨다.

2. 그 값을 리턴한다.

 

복사본을 만든다는 과정의 차이가 생깁니다.

복사본이라는 것은 작은 프리미티브 타입이라면 괜찮으나, 사용자 정의형 데이터(구조체나 클래스) 등의 복사 비용은 생각보다 만만하지 않습니다.

 

결론으로는 프리미티브 타입은 어느 방식을 사용해도 거의 차이가 없지만,

사용자 정의형 데이터라면 접두어 방식이 조금 더 효율적입니다.

 

현업에서도 C++ 구조로 짜시는 분들은 접두어 방식을 많이 사용하고,

C# 구조로 짜시는 분들은 접미어 방식을 많이 사용하는 것 같습니다. 물론 C# 은 foreach 방식도 있으니..

 

이왕이면 접두어 방식을 사용합시다!

'CS > C++' 카테고리의 다른 글

클래스 설계  (0) 2024.07.28
클래스 상속  (0) 2024.07.27

프로퍼티의 마지막 시간입니다.

무명 형식, 인터페이스와 프로퍼티, 추상 클래스의 프로퍼티 배울 것이 많아 보입니다.

빠르게 공부해봅시다!

 

# 무명 형식 (Anonymous Type)

제목 그대로 이름이 없는 형식을 무명 형식이라고 합니다.

보통은 임시로 사용하는 경우 특별히 다시 사용하지 않기 때문에 람다처럼 이름을 붙이지 않습니다.

 

무명 형식은 형식의 선언과 동시에 인스턴스를 할당합니다.

인스턴스를 만들고 다시는 사용하지 않을 때 무명 형식이 요긴합니다.

var tempInstance = new {Name = "나, 무명", Age = 20};

무명 형식의 프로퍼티에 할당된 값은 변경 불가능합니다. 읽기만 가능하죠.

나중에 LINQ(링큐)에서 요긴하다고 합니다.

using System;

namespace AnonymousType
{
    class Program
    {
        static void Main()
        {
            // 무명 형식은 형식의 선언과 동시에 인스턴스를 할당, 이후에는 Read만 가능합니다.
            var a = new { Name = "지코", Age = 29 };
            Console.WriteLine($"Name:{a.Name}, Age:{a.Age}");

            var b = new { Subject = "Math", Scores = new int[] { 90, 80, 70, 60 } };
            Console.Write($"Subject:{b.Subject}, Scores: ");
            foreach(var score in b.Scores)
                Console.Write($"{score} ");

            Console.WriteLine();
        }
    }
}
Name:지코, Age:29
Subject:Math, Scores: 90 80 70 60

# 인터페이스의 프로퍼티

기존에도 공부했지만 인터페이스는 메서드, 프로퍼티, 인덱서를 가질 수 있습니다.

게다가 인터페이스에서는 구현부를 만들지 않습니다. 이 개념을 가지고 살펴봅시다.

 

인터페이스에도 프로퍼티를 선언할 수 있는데 구현을 갖지 않습니다. 그래서 프로퍼티는 구현을 갖지 않습니다.

그런데 인터페이스를 상속하는 클래스는 반드시 해당 프로퍼티와 인덱서를 구현해야 합니다!

 

신기하게도 인터페이스에 프로퍼티를 선언할 때 기존 클래스에서 선언하는 거랑 똑같지만 자동 구현 프로퍼티를 C# 컴파일러가 자동으로 구현해주지 않습니다. 인터페이스는 어떤 구현도 가지지 않기 때문이죠.

using System;

namespace PropertiesInInterface
{
    interface INamedValue
    {
        string Name { get; set; } // 자동으로 구현해주지 않습니다.
        string Value { get; set; }
    }

    class NamedValue : INamedValue
    {
        public string Name { get; set; } // 자동으로 구현해줍니다.
        public string Value { get; set; }
    }
    
    class Program
    {
        static void Main()
        {
            NamedValue name = new NamedValue()
            {
                Name = "이름",
                Value = "지코"
            };

            NamedValue height = new NamedValue()
            {
                Name = "키",
                Value = "181cm"
            };

            NamedValue weight = new NamedValue()
            {
                Name = "몸무게",
                Value = "67kg"
            };

            Console.WriteLine($"{name.Name} : {name.Value}");
            Console.WriteLine($"{height.Name} : {height.Value}");
            Console.WriteLine($"{weight.Name} : {weight.Value}");
        }
    }
}
이름 : 지코
키 : 181cm
몸무게 : 67kg

# 추상 클래스의 프로퍼티

추상 클래스도 앞서 공부한 인터페이스와 유사합니다.

다만 구현된 프로퍼티를 가질 수 있고, 인터페이스처럼 구현되지 않은 프로퍼티도 가질 수 있습니다.

 

인터페이스처럼 구현되지 않는 프로퍼티를 추상 프로퍼티라고 하며, 파생 클래스가 해당 프로퍼티를 구현하도록 강제하는 것입니다. 프로퍼티 앞에 abstract 키워드를 붙여야 합니다.

using System;

namespace PropertiesInAbstractClass
{
    abstract class Product
    {
        private static int serial = 0;
        public string SerialID
        {
            get { return String.Format("{0:d5}", serial++); }
        }

        abstract public DateTime ProductDate { get; set; } // 자동으로 구현 x
    }

    class MyProduct : Product
    {
        public override DateTime ProductDate { get; set; }
    }

    class Program
    {
        static void Main()
        {
            Product product_1 = new MyProduct()
            {
                ProductDate = new DateTime(2021, 12, 20)
            };

            Console.WriteLine("Product:{0}, Product Date:{1}", product_1.SerialID, product_1.ProductDate);

            Product product_2 = new MyProduct()
            {
                ProductDate = new DateTime(2021, 12, 21)
            };

            Console.WriteLine("Product:{0}, Product Date:{1}", product_2.SerialID, product_2.ProductDate);
        }
    }
}
Product:00000, Product Date:2021-12-20 오전 12:00:00
Product:00001, Product Date:2021-12-21 오전 12:00:00

 

'CS > C#' 카테고리의 다른 글

[C#] Record 와 Property  (0) 2021.12.21
[C#] property, 프로퍼티  (0) 2021.12.21
[C#] 인터페이스와 추상 클래스  (0) 2021.12.20
[C#] 정적 필드와 메서드(static)  (0) 2021.12.12
[C#] 생성자와 소멸자  (0) 2021.12.11

프로퍼티 두 번째 시간입니다.

 

# 프로퍼티란?

레코드에 들어가기 전에 참조 형식과 값 형식에 대해서 잠깐 언급하고 가보겠습니다.

값 형식은 필드가 많으면 많을수록 복사 비용이 커지게 됩니다. 왜냐하면 깊은 복사로 새 객체가 기존에 가진 모든 필드를 1:1 복사하기 때문이죠.

그렇지만 참조 형식에서는 이런 오버헤드가 없습니다. 객체가 참조하는 메모리 주소만 복사하면 되기 때문입니다. 하지만 참조는 얕은 복사를 진행하며, 깊은 복사가 필요한 경우 따로 프로그래머가 구현해줘야 합니다.

 

더 들어가봅시다.

값 형식은 객체를 비교할 때에도 기본적으로 내용을 비교하는데 모든 필드를 1:1 비교합니다. 불변 객체에서 필요한 방법입니다.

참조 형식은 내용 비교를 할 수 있으려면 프로그래머가 직접 비교 코드를 작성해야 합니다. 이때 object로부터 상속하는 Equals() 메서드를 오버 라이딩해서 구현합니다.

 

정리하면 불변 객체를 참조 형식으로 선언하면 함수 호출 인수나 컬렉션 요소를 사용할 때 복사 비용을 줄일 수 있습니다. 즉, 값 형식의 좋은 점과 참조 형식의 좋은 점을 쏙 체리 픽해서 가져와 사용합니다.

 

이것을 지원하는 것이 바로 오늘 배울 레코드입니다.

 

레코드 형식은 값 형식처럼 다룰 수 있는 불변 참조 형식(내부 상태를 변경할 수 없는 객체 = Immutable)으로, 참조 형식의 비용 효율 + 값 형식이 주는 편리함 모두를 제공합니다.

 

# 레코드 선언하기

record RTransaction
{
    public string From { get; init; }
    public string To { get; init; }
    public int Amount { get; init; }
}

기존 프로퍼티 사용 방법과 동일하지만 record 키워드를 사용합니다.

이렇게 선언한 레코드로 인스턴스(객체)를 만들면, 불변 객체가 만들어집니다.

 

레코드는 어디서에 빛을 발휘할까요?

복사할 때, 비교할 때 유용합니다.

 

# 레코드 복사하기

RTransaction tr2 = tr1 with { To = "Charlie" };
RTransaction tr3 = tr2 with { From = "Dave", Amount = 30 };

C# 컴파일러는 레코드 형식을 위한 복사 생성자를 자동으로 작성합니다. 왜?? 복사하기 위해서죠.

그런데 이 복사 생성자는 protected로 선언되어 있기 때문에 명시적으로 호출할 수 없습니다.

하지만 with 식을 이용한다면 우측의 레코드를 좌측으로 복사할 수 있으며 { } 안에 적은 프로퍼티를 변경해서 저장할 수 있습니다.

 

with 식은 객체 상태(프로퍼티)가 다양할수록 유용합니다.

인스턴스를 새로 할당할 때 모든 프로퍼티를 입력하지 않아도 되기 때문입니다.

using System;

namespace WithExp
{
    record RTransaction
    {
        public string From { get; init; }
        public string To { get; init; }
        public int Amount { get; init; }
        public override string ToString()
        {
            return $"{From,-10} -> {To,-10} : ${Amount}";
        }
    }

    class Program
    {
        static void Main()
        {
            RTransaction tr1 = new RTransaction { From = "Alice", To = "Bob", Amount = 100 };
            RTransaction tr2 = tr1 with { To = "Charlie" };
            RTransaction tr3 = tr2 with { From = "Dave", Amount = 30 };

            Console.WriteLine(tr1);
            Console.WriteLine(tr2);
            Console.WriteLine(tr3);
        }
    }
}
Alice      -> Bob        : $100
Alice      -> Charlie    : $100
Dave       -> Charlie    : $30

#레코드 객체 비교하기

C# 컴파일러는 레코드의 상태를 비교하는 Equals() 메서드를 자동으로 구현합니다.

기존 클래스 비교였다면 프로그래머가 직접 object로부터 상속받은 Equals() 메서드를 구현해야 했지만, 레코드는 C# 컴파일러가 자동으로 구현해주니까 단순이 선언만 하면 끝입니다.

 

아래의 코드는 프로그래머가 기존 클래스 비교에서 구현을 하지 않았다면 참조의 비교 형태와 레코드의 Equals()를 보여줍니다.

using System;

namespace RecordComp
{
    class CTransaction
    {
        public string From { get; init; }
        public string To { get; init; }
        public int Amount { get; init; }
        public override string ToString()
        {
            return $"{From,-10} -> {To,-10} : ${Amount}";
        }
    }

    record RTransaction
    {
        public string From { get; init; }
        public string To { get; init; }
        public int Amount { get; init; }
        public override string ToString()
        {
            return $"{From,-10} -> {To,-10} : ${Amount}";
        }
    }

    class Program
    {
        static void Main()
        {
            CTransaction trA = new CTransaction { From = "Alice", To = "Bob", Amount = 100 };
            CTransaction trB = new CTransaction { From = "Alice", To = "Bob", Amount = 100 };

            Console.WriteLine(trA);
            Console.WriteLine(trB);
            // 기본적으로 Equals는 참조를 비교하므로 따로 정의하지 않았다면 참조 비교로 인해 false
            Console.WriteLine($"trA equals to trB : {trA.Equals(trB)}");

            RTransaction tr1 = new RTransaction { From = "Alice", To = "Bob", Amount = 100 };
            RTransaction tr2 = new RTransaction { From = "Alice", To = "Bob", Amount = 100 };

            Console.WriteLine(tr1);
            Console.WriteLine(tr2);
            Console.WriteLine($"tr1 equals to tr2 : {tr1.Equals(tr2)}");
        }
    }
}
Alice      -> Bob        : $100
Alice      -> Bob        : $100
trA equals to trB : False
Alice      -> Bob        : $100
Alice      -> Bob        : $100
tr1 equals to tr2 : True

클래스 비교에서 Equals()를 오버 라이딩해서 구현하지 않았다면 참조(주소 값)만 비교하기 때문에 false가 나오게 됩니다. 하지만 레코드의 비교는 컴파일러가 자동으로 구현해주기 때문에 참조 비교가 아닌 진짜 값을 비교하므로 true입니다.

 

오늘은 record 에서 배웠습니다.

record와 프로퍼티는 어떤 점이 달랐는지 record의 장점과 왜 사용하는지에 대해서도 배웠습니다.

 

 

오늘은 프로퍼티에 대해서 배워보겠습니다.

기존에 C++을 공부를 했었고, 은닉성을 위해서는 public으로 필드 멤버를 선언하는 것이 아닌 private로 감추고

필요한 부분만 getter, setter를 구현해서 사용하라고 배웠습니다.

맞는 방법이지만 한 변수에 getter와 setter까지 하면 변수가 많아질수록 이 수 또한 엄청 많아지게 될 것입니다.

C#에서는 이러한 것들을 한 뭉치로 묶을 수 있는 프로퍼티를 제공합니다!

 

# 프로퍼티

class Program
{
    private int age;
    public int Age
    {
        get { return age; }
        set { age = value; }
    }
}

age라는 것을 Age라는 프로퍼티로 제공하게 됩니다.

Program p = new Program();
p.Age = 5; // 쉬운 setter
Console.WriteLine(p.Age); // 쉬운 getter

위와 같은 방식으로 쉽게 쓰고 대입할 수 있습니다.

 

좀 더 만들어봅시다.

using System;

namespace Property
{
    class BirthdayInfo
    {
        private string name;
        private DateTime birthday;

        public string Name // 프로퍼티!
        {
            get { return name; }
            set { name = value; }
        }

        public DateTime Birthday
        {
            get { return birthday; }
            set { birthday = value; }
        }

        public int Age
        {
            get
            {
                return new DateTime(DateTime.Now.Subtract(birthday).Ticks).Year;
            }
        }
    }

    class Program
    {
        static void Main()
        {
            BirthdayInfo birth = new BirthdayInfo();
            birth.Name = "지코";
            birth.Birthday = new DateTime(1992, 9, 14);

            Console.WriteLine($"Name : {birth.Name}");
            Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
            Console.WriteLine($"Age : {birth.Age}");
        }
    }
}

# 자동 구현 프로퍼티 (Auto-Implemented-Property)

위의 프로퍼티도 매력적이지만 좀 더 줄일 수 있습니다.

class Program
{
    private int age;
    public int Age { get; set; }
}

이렇게 작성하면 단순히 age를 읽고 쓰는 기능이기 때문에, C# 컴파일러가 자동으로 프로퍼티를 구현해줍니다.

 

C# 7.0부터는 자동 구현 프로퍼티 선언과 동시에 초기화도 가능하게 지원합니다.

class Program
{
    private int age;
    public int Age { get; set; } = 10;
}

자동 구현 프로퍼티의 선언과 동시에 초기화하는 예제를 연습해봅시다.

using System;

namespace AutoImplementedProperty
{
    class BirthdayInfo
    {
        public string Name { get; set; } = "UnKnown";
        public DateTime Birthday { get; set; } = new DateTime(1, 1, 1);
        public int Age
        {
            get
            {
                return new DateTime(DateTime.Now.Subtract(Birthday).Ticks).Year;
            }
        }
    }

    class Program
    {
        static void Main()
        {
            BirthdayInfo birth = new BirthdayInfo();
            Console.WriteLine($"Name : {birth.Name}");
            Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
            Console.WriteLine($"Age : {birth.Age}");

            birth.Name = "지코";
            birth.Birthday = new DateTime(1992, 9, 14);
            Console.WriteLine($"Name : {birth.Name}");
            Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
            Console.WriteLine($"Age : {birth.Age}");
        }
    }
}

 

# 자동 구현 프로퍼티의 뒷단

자동 구현 프로퍼티가 우리에게 편리함을 가져줍니다.

class Program
{
    //private int age; 이걸 쓰지 않아도 알아서 컴파일러가 뒷단에서 구현해줍니다.
    public int Age { get; set; } = 10;
}

기존에는 private로 age를 만들고 앞에 Age로 프로퍼티를 지원했지만, 그냥 Age 프로퍼티만 선언한다면 C# 컴파일러가 자동으로 구현해줍니다. 이것 때문에 우리는 좀 더 편안한 프로그래밍이 가능해집니다.

 

# 프로퍼티와 생성자

기존에 클래스는 클래스를 생성하면서 안에 멤버 필드를 초기화하고 싶다면 생성자를 만들어서 사용했습니다. 그런데 프로퍼티가 등장으로 객체를 생성할 때 각 필드를 초기화하는 또 다른 방법이 있습니다.

 

Name과 Birthday는 각각의 string과 DateTime의 프로퍼티입니다.

객체를 생성할 때 각각의 프로퍼티를 초기화하는 과정입니다.

초기화하고 싶은 프로퍼티만 사용할 수 있습니다.

BirthdayInfo birth = new BirthdayInfo()
{
    Name = "지코",
    Birthday = new DateTime(1992, 9, 14)
};
using System;

namespace ConstructorWithProperty
{
    class BirthdayInfo
    {
        public string Name { get; set; }
        public DateTime Birthday { get; set; }
        public int Age
        {
            get
            {
                return new DateTime(DateTime.Now.Subtract(Birthday).Ticks).Year;
            }
        }
    }

    class Program
    {
        static void Main()
        {
            BirthdayInfo birth = new BirthdayInfo()
            {
                Name = "지코",
                Birthday = new DateTime(1992, 9, 14)
            };

            Console.WriteLine($"Name : {birth.Name}");
            Console.WriteLine($"Birthday : {birth.Birthday.ToShortDateString()}");
            Console.WriteLine($"Age : {birth.Age}");
        }
    }
}

 

# 초기화 전용(Init-Only) 자동 구현 프로퍼티

기존에 초기화한 필드를 다시 바꾸려는 것을 막는 키워드가 있었습니다.

예를 들어서 readonly처럼 한 번 초기화하면 다음 변경을 막아버리는 키워드가 그 예시입니다.

 

프로퍼티도 이러한 기능을 지원합니다.

    class Transaction
    {
        public string From { get; init; }
        public string To { get; init; }
        public int Amount { get; init; }

        public override string ToString()
        {
            return $"{From,-10} -> {To,-10} : ${Amount}";
        }
    }

init 접근자는 set 접근자처럼 외부에서 프로퍼티 변경할 수 있지만 객체를 초기화할 때만 프로퍼티 변경이 가능하다는 점입니다. 이렇게 선언한 프로퍼티를 초기화 전용 자동 구현 프로퍼티라고 합니다.

using System;

namespace InitOnly
{
    class Transaction
    {
        public string From { get; init; }
        public string To { get; init; }
        public int Amount { get; init; }

        public override string ToString()
        {
            return $"{From,-10} -> {To,-10} : ${Amount}";
        }
    }

    class Program
    {
        static void Main()
        {
            Transaction tr1 = new Transaction { From = "Alice", To = "Bob", Amount = 100 };
            Transaction tr2 = new Transaction { From = "Bob", To = "Charlie", Amount = 50 };
            Transaction tr3 = new Transaction { From = "Charlie", To = "Alice", Amount = 50 };

            Console.WriteLine(tr1);
            Console.WriteLine(tr2);
            Console.WriteLine(tr3);
        }
    }
}

각 트랜잭션은 객체를 생성할 때 각 프로퍼티를 초기화하고, 그 이후부터는 읽기 전용으로 사용하고 있습니다.

 

오늘은 프로퍼티가 왜 나오게 됐는지, 어떻게 사용하는지에 대해서 배웠습니다.

+ Recent posts