C++/효과적인 C++

[Effective C++] 공부 정리 5

econo-my 2024. 10. 25. 11:21

항목 18 : 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자

  • 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵다. 인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하자.
  • 인터페이스의 올바른 사용을 이끄는 방법으로 는 인터페이스 사이의 일관성을 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있다.
  • 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있다.

 

항목 19 : 클래스 설계는 타입 설계와 똑같이 취급하자

  • C++에서 새로운 클래스를 정의한다는 것은 새로운 타입을 하나 정의하는 것과 같다.
  • 효과적인 클래스를 설계하기 위한 고려사항들
    • 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가?
      • 이 부분이 어떻게 되느냐에 따라 클래스 생성자 및 소멸자의 설계가 바뀐다.
    • 객체 초기화는 객체 대입과 어떻게 달라야 하는가?
      • 복사 생성자와 복사 대입 함수를 생각하자.
    • 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가?
      • 값에 의한 전달을 구현하는 쪽은 바로 복사 생성자이다.
    • 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가?
    • 기존의 클래스 상속 계통망(inheritance graph) 에 맞출 것인가?
      • 기존의 클래스를 상속 받는다면 우리의 설계는 이들 클래스에 제약을 받는다.
      • 다른 클래스들이 우리의 클래스를 상속받게 하고자 한다면, 가상 함수의 여부를 잘 정하자.
    • 어떤 종류의 타입 변환을 허용할 것인가?
    • 어떤 연산자와 함수를 두어야 의미가 있을까?
    • 표준 함수들 중 어떤 것을 허용하지 말 것인가?
    • 새로운 타입의 멤버에 대한 접근권한을 어느쪽에 줄 것인가?
    • ‘선언되지 않은 인터페이스’로 무엇을 둘 것인가?
    • 새로 만드는 타입이 얼마나 일반적인가?
    • 정말로 꼭 필요한 타입인가?

 

항목 20 : ‘값에 의한 전달’ 보다는 ‘상수 객체 참조자에 의한 전달’ 방식을 택하는 편이 대개 낫다

  • 값에 의한 전달은 고비용의 연산이 되기도 한다.
class Person
{
public:
	Person();
	virtual ~Person();
	
	...
	
private:
	std::string name;
	std::string address;
};

class Student : public Person
{
public:
	Student();
	~Student();
	
private:
	std::string schoolName;
	std::string schoolAddress;
};
  • 이제 아래의 코드를 봐보자.
bool validateStudent(Student s); //Student를 값으로 전달받는 함수

Student plato;
bool platoIsOk = validateStudent(plato); //함수 호출
  • s를 plato로 초기화하기 위해 복사 생성자 1, 소멸자 1
  • Person 복사 생성자 1, 소멸자 1
  • Person의 string 멤버 복사 생성자 2, 소멸자 2
  • Student의 string 멤버 복사 생성자 2, 소멸자 2
  • 총 생성자 여섯 번에 소멸자 여섯 번의 연산 비용이 든다.
  • 상수 객체에 대한 참조자(reference-to-const)로 전달하게 하면 몇 번씩 생성자와 소멸자를 호출하지 않아도 된다.
bool validateStudent(const Student& s);
  • 함수를 아래와 같이 바꾼 뒤 파생 클래스 타입인 Student 객체를 인자로 넣으면 어떻게 될까? 정답은 복사 손실이 일어난다.
  • Student 객체의 구실을 할 수 있는 부속 정보는 싹둑 잘려 나간채 Person 클래스 객체만의 기능을 사용해야 한다.
bool validateStudent(Person p);
  • 하지만 상수 객체에 대한 참조자로 전달한다면 복사 손실 문제를 해결할 수 있다.

 

요약

  • ‘값에 의한 전달’이 저비용일 때 사용해도 되는 상황 3 가지
    • 기본제공 타입, STL 반복자, 함수 객체 타입
  • 이 외 상황을 제외하면 모두 ‘상수 객체 참조자에 의한 전달’을 선택하자.
  • ‘값에 의한 전달’ 보다는 ‘상수 객체 참조자’을 선호하자. 대체적으로 효율적일뿐만 아니라 복사 손실 문제까지 막아준다.

 

항목 21 : 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자

  • 참조자는 어떤 것에 대한 ‘또 다른’ 이름이어야 한다.
class Rational
{
public:
	Rational(int Num = 0, int Denominator = 1);
	Rational(const Rational& rational);

private:
	int N, D;

	friend const Rational operator*(const Rational& lhs, const Rational& rhs);
};
  • operator* 의 반환 타입에 const를 붙이고 싶을 수 있지만 객체를 반환해야 하는 경우에는 붙이면 안된다.
//객체를 스택에 만들어서 반환하는 경우
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
	Rational result(lhs.N * rhs.N, lhs.D * rhs.D);
	return result;
}
  • 지역 객체의 경우 함수가 끝나면 사라진다. 비어있는 메모리를 참조해 미정의 동작의 세계로 빠진다.
//객체를 힙에 만들어서 반환하는 경우
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
	Rational* result = new Rational(lhs.N * rhs.N, lhs.D * rhs.D);
	return *result;
}
  • new와 delete는 한 쌍으로 와야된다고 했다. 메모리 누수가 그야말로 ‘확정’ 인 함수의 완성이다.
//함수 내 정적 객체 반환
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
	static Rational result;
	
	result = ....;
		
	return result;
}
  • operator== 를 통해 비교 연산에서 문제가 생긴다.
inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
	return Rational(lhs.N * rhs.N, lhs.D * rhs.D);
}
  • 해당 코드처럼 작성하는 것이 대부분 옳다.

 

요약

  • 지역 객체를 참조 반환하지 말자.
  • 지역 정적 객체에 대한 포인터나 참조자를 반환하지 말자.

 

항목 22 : 데이터 멤버가 선언될 곳은 private 영역임을 명심하자.

  • 데이터 멤버는 private 영역으로 캡슐화 하는 것이 좋다.
  • 사용자로부터 데이터 멤버를 캡슐화시 장점
    • 클래스의 불변속성을 유지 하는데 도움을 준다.
    • 나중에 변경할 수 있는 기회가 있다.
  • public이란 ‘캡슐화되지 않았다’ 라는 의미로 사방에서 쓰이고 있기 때문에 ‘바꿀 수 없다’ 라는 의미를 지니고 있다.
  • 어떤 것이 바뀌면 깨질 가능성을 가진 코드가 늘어날 때 캡슐화의 정도는 그에 반비례해서 작아진다.
  • protected 라 해서 private 과 같은 캡슐화가 되지 않는다.
    • 클래스의 상속 깊이가 깊어질수록 protected로 선언된 데이터 멤버가 바뀔 시 파급력이 엄청나진다.

 

요약

  • 데이터 멤버는 private 멤버로 선언하자.
  • 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐 아니라, 내부 구현의 융통성도 발휘할 수 있다.

 

항목 23 : 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자

  • 웹브라우저를 나타내는 클래스가 하나 있다고 가정하자.
class WebBrowser
{
public:
	void ClearCache(); //캐시를 비우는 함수
	void ClearHistory(); //방문한 URL 기록을 없애는 함수
	void RemoveCookies(); //쿠키를 전부 제거하는 함수
};
  • 해당 함수 3가지를 한번에 실행해주는 함수도 준비해놓을 수 있다.
//멤버 함수에 구현
class WebBrowser
{
public:
	void ClearEverything(); //위 세 함수를 모두 호출하는 함수
};

//비멤버 함수에 구현
void ClearBrowser(WebBrowser& Wb)
{
	Wb.ClearCache();
	Wb.ClearHistory();
	Wb.RemoveCookies();
}
  • 멤버 함수에 구현한 것이 비멤버 함수에 구현한 것보다 캡슐화 정도가 현저히 떨어진다.
  • 비멤머 함수로 구현했을 시 이점은 WebBrowser 관련 기능을 구성하는 데 있어서 패키징 유연성이 높아지고, 컴파일 의존도를 낮출 수 있으며, 확정성도 높일 수 있다.
  • 캡슐화를 살펴보며 그 이유들을 알아보자.
  • 캡슐화를 하면 외부에서 볼 수 있는 것들이 줄어든다.
    • 외부에서 볼 수 있는 것들이 줄어들면 바꿀 때 필요한 유연성이 커진다.
    • 코드 변경에 여유가 생긴다.
    • 데이터에 접근하는 함수가 많을 수록 데이터의 캡슐화 정도는 낮아진다.
  • 비멤버 비프렌드 함수는 어떤 클래스의 private 멤버 부분을 접근할 수 있는 함수의 개수를 늘리지 않으며, 이는 곧 캡슐화 정도를 높인다고 볼 수 있다.
    • 멤버 함수와 비멤버 ‘비프렌드’ 함수 사이 결정하고 있는 것을 기억해놓자.
//C++로 비멤버 함수를 더 자연스런 방법으로 구사하는 방법
namespace WebBrowserStuff
{
	class WebBrowser{};

	void ClearBrowser(WebBrowser& Wb);
}
  • 용도에 따라 함수를 나눠 함수에 대한 컴파일 의존성 고민을 줄일 수 있다.
  • 방법은 관련 함수를 하나의 헤더 파일에 몰아서 선언하는 것이다.
// "webbrowser.h" 헤더 - WebBrowser 클래스 자체에 대한 헤더
// 그리고 WebBrowser에 관련된 '핵심' 기능들이 선언되어 있음
namespace WebBrowserStuff
{
	class WebBrowser{};

	// '핵심' 관련 기능 이를테면 거의 모든 사용자가 써야 하는 비멤버 함수들
}

// "webbrowserbookmarks.h" 헤더
namespace WebBrowserStuff
{
	// 즐겨찾기 관련 편의 함수들
}

// "webbrowsercookies.h" 헤더
namespace WebBrowserStuff
{
	// 쿠키 관련 편의 함수들
}
  • 표준 C++ 라이브러리가 이러한 구조로 구성되어 있다.
    • std 네임스페이스에 속한 모든 것들이 하나의 헤더가 아닌, 몇 개의 기능과 관련된 함수들이 수십 개의 헤더 (<vector>, <algorithm> 등)에 흩어져 선언되어 있다.
    • 그래서 vector 컨테이너만 사용하고 싶다면 #include <vector> 만 함으로써 컴파일 의존성을 줄일 수 있단 것이다.
  • 반면 클래스 멤버 함수의 경우 이러한 방법으로 쪼갤 수 없다. 하나의 클래스는 그 전체가 통으로 정의되어야 하고 여러 조각으로 나눌 수가 없기 때문이다.

 

요약

  • 멤버 함수보다는 비멤버 비프렌드 함수를 자주 쓰도록 하자.
  • 캡슐화 정도가 높아지고, 패키징 유연성도 커지며, 기능적인 확장성도 늘어난다.

 

항목 24 : 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자.

  • 클래스에서 암시적 타입 변환을 지원하는 것은 일반적으로 못된 생각이다.
  • 예외 상황이 있다면 숫자 타입을 만들 때이다.
  • 유리수 클래스를 만들어보며 예외 상황을 알아보자
class Rational
{
public:
	//explicit을 붙이지 않아 암시적 형변환을 허용한다.
	Rational(int numerator = 0, int denominator = 1);

	int numerator() const;
	int denominator() const;

private:
	//...
};
  • 위 클래스에서 유리수 연산 기능을 멤버 함수, 비멤버 함수, 비멤버 프렌드 함수로 나눠 구현해보자.
  • 멤버 함수 operator* 구현
class Rational
{
public:
	const Rational operator*(const Rational& rhs) const;
}
  • 이렇게 설계해 두면 유리수 곱셈을 할 수 있게 된다.
Rational OneEighth(1, 8);
Rational OneHalf(1, 2);
Rational result = OneHalf * OneEighth;

result = result * OneEighth;
  • 하지만 혼합형 수치 연산시 에러가 나타난다.
result = OneHalf * 2; //정상작동
result = 2 * OneHalf; //에러!

-- 같은 의미의 코드 --

result = OneHalf.operator*(2);
result = 2.operator*(OneHalf); 
  • 현재 operator* 의 인자는 Rational 객체인데 정수 2가 먹히는 이유는 다음과 같다.
const Rational temp(2); //2로부터 임시 Rational 객체를 생성한다.

result = OneHalf * temp; //OneHalf.operator*(temp); 와 같다.
  • 두번째 문장은 비명시호출 생성자와 함께 했지만 매개변수 리스트에 들어 있지 않아 컴파일 되지 않는다.
  • 암시적 타입 변환에 대해 매개변수가 먹혀들려면 매개변수 리스트에 들어 있어야만 한다.
  • 비멤버 함수 operator* 구현
class Rational { ... }; //operator* 가 없다.

const Rational operator*(const Rational& lhs, const Rational& rhs)
result = OneHalf * 2; //정상작동!
result = 2 * OneHalf; //정상작동!
  • 비멤버 비프렌드 operator* 는 구현하지 않아도 된다.
    • Rational의 public 인터페이스만 써서 구현할 수 있기 때문이다.
  • 이로써 “멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수이다.” 라는 결론을 얻을 수 있다.

 

요약

  • 어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서) 에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 한다.
  • 암시적 형변환을 허용하려면 생성자에 explicit 키워드를 빼주자.

 

항목 25 : 예외를 던지지 않는 swap에 대한 지원도 생각해보자.

  • C++ 표준 라이브러리에서 제공하는 swap 알고리즘을 살펴보자.
namespace std
{
	template<typename T>
	void swap(T& a, T& b)
	{
		T temp(a);
		a = b;
		b = temp;
	}
}
  • 한 번 호출에 복사가 세 번이나 일어나게 된다.
  • 복사하면 손해를 보는 타입들 중 으뜸을 꼽는다면 다른 타입의 실제 데이터를 가리키는 포인터가 주성분인 타입일 것이다.
  • 이러한 개념을 설계의 미학으로 끌어올려 많이들 쓰고 있는 기법이 바로 pimpl 관용구 (’pointer tot implementation’) 이다.
//Widget의 실제 데이터를 나타내는 클래스
class WidgetImpl
{
public:
	//세부사항은 중요하지 않다.
	
private:
	int a, b, c;
	std::vector<double> v; //복사 비용이 높다!
	//...
};

class Widget //pimpl 관용구를 사용한 클래스
{
public:
	Widget(const Widget& rhs);
	Widget& operator=(const Widget& rhs) //Widget을 복사하기 위해, 자 신의 WidgetImpl 객체를 복사한다.
	{									 
		//...

		*pImpl = *(rhs.pImpl);

		//...
	}

private:
	WidgetImpl* pImpl;	//Widget의 실제 데이터를 가진 객체에 대한 포인터
};