본문 바로가기
프로그래밍/Effective C++

Chapter 4 설계 및 선언

by Ohdumak 2018. 4. 17.

2018/04/11 - [프로그래밍/Effective C++] - Chapter 3 자원 관리


소프트웨어 설계(소프트웨어가 원하는 동작을 하도록 틀을 짜는 방법)


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


C++에서는 발에 치이고 손에 잡히는 것이 인터페이스이다. 어떤 인터페이스를 어떻게 써 봤는데 결과 코드가 사용자가 생각한 대로 동작하지 않는다면 그 코드는 컴파일되지 않아야 맞다. 거꾸로 어떤 코드가 컴파일된다면 그 코드는 사용자가 원하는 대로 동작해야 한다.


'제대로 쓰기에 쉽고 엉터리로 쓰기에 어려운' 인터페이스를 개발하려면 우선 사용자가 저지를 만한 실수의 종류를 머리에 넣어두고 있어야 한다.


기본제공 타입과 쓸데없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서이다.


사용자 쪽에서 뭔가를 외워야 제대로 쓸 수 있는 인터페이스는 잘못 쓰기 쉽다. 


부스트 라이브러리의 shared_ptr 클래스를 사용하면 원시 포인터보다 크고 느리며 게다가 내부 관리용 동적 메모리까지 추가로 매달린다. 하지만 이런 것들 때문에 응용프로그램에서 런타임 비용이 눈에 띄게 늘어나는 경우는 어지간해서는 찾기 힘들다. 반면에 사용자 실수가 눈에 띄게 줄어드는 경우는 모든 사람들이 잡아낼 수 있을 정도이다.


이것만은 잊지 말자!

- 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵다. 인터페이스를 만들때는 이 특성을 지닐 수 있도록 고민하고 또 고민하자.

- 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있다.

- 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있다.

- tr1::shared_ptr은 사용자 정의 삭제자를 지원한다. 이 특징 때문에 tr1::shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있다.



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


여느 객체 지향 프로그래밍 언어와 마찬가지로, C++에서 새로운 클래스를 정의한다는 것은 새로운 타입을 하나 정의하는 것과 같다. 클래스를 설계할 때는 마치 언어 설계자가 그 언어의 기본제공 타입을 설계하면서 쏟아 붓는 것과 같은 정성과 보살핌이 필요하다.


효과적인 클래스를 설계하기 위해

- 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이뤄져야 하는가?

- 객체 초기화는 객체 대입과 어떻게 달라야 하는가? (항목 4 참조)

- 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가? 어떤 타임에 대해 '값에 의한 전달'을 구현하는 쪽은 바로 복사 생성자이다.

- 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가? 

- 기존의 클래스 상속 계통망(inheritance graph)에 맞출 것인가?

- 어떤 종류의 타입 변환을 허용할 것인가?

- 어떤 연산자와 함수를 두어야 의미가 있을까?

- 표준 함수들 중 어떤 것을 허용하지 말 것인가?

- 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?

-'선언되지 않은 인터페이스'로 무엇을 둘 것인가?

- 새로 만드는 타입이 얼마나 일반적인가?

- 정말로 꼭 필요한 타입인가?


어느 것 하나 만만하게 볼 수 없는 질문이다.


이것만은 잊지 말자!

- 클래스 설계는 타입 설계이다. 새로운 타입을 정의하기 전에, 이번 항목에 나온 모든 고려사항을 빠짐없이 점검해 보자.



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


기본적으로 C++는 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달(pass-by-value)' 방식을 사용한다. '값에 의한 전달'이 고비용의 연산이 된다. 그래서 상수객체에 대한 참조자(reference-to-const)로 전달하자.


bool validateStudent(Student s);


bool validateStudent(const Student& s);


참조에 의한 전달 방식으로 매개변수를 넘기면 복사손실 문제(slicing problem)가 없어지는 장점도 있다.


void printNameAndDisplay(Window w);


void printNameAndDisplay(const Window& w);


전달하는 객체의 타입이 기본제공 타입(int 등)일 경우에는 참조자로 넘기는 것보다 값으로 넘기는 편이 더 효율적일 때가 많다.


하지만, '그냥 크기가 작으니까'는 그 객체의 복사 생성자 호출이 저비용이란 뜻으로 해석하라는 단서가 아니다.


일반적으로, '값에 의한 전달'이 저비용이라고 가정해도 괜찮은 유일한 타입은 기본제공 타입, STL 반복자, 함수 객체 타입, 세가지다.


이것만은 잊지 말자!

- '값에 의한 전달'보다는 '상수 객체 참조자에 의한 전달'을 선호합시다. 대체적으로 효율적일뿐만 아니라 복사손실 문제까지 막아 준다.

- 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않는다. 이들에 대해서는 '값에 의한 전달'이 더 적절하다.



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


새로운 객체를 반환해야 하는 함수를 작성하는 방법에는 정도가 있다. 바로 '새로운 객체를 반환하게 만드는 것'이다.

inline const Rational operator *(const Rational& lhs, const Rational &rhs) {
	return Rational(lhs.n *rhs.n, lhs.d *rhs.d);
}

참조자를 반환할 것인가 아니면 객체를 반환할 것인가를 결정할 때, 어떤 선택을 하든 오바른 동작이 이루어지도록 만드는 것! 선택한 결과를 최대한 저비용으로 만들려면 어떻게 해야 하는지 파악하느라 끙끙대는 일은 컴파일러 제작사에 맡겨라.


이것만은 잊지 말자!

- 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 객체에 대한 포인터난 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 말자. (항목 4, 지역 정적 객체에 대한 참조자를 반환하도록 설계된 올바른 코드 예제 포함, 최소한 단인 스레드 환경에서 동작)



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


문법적 일관성: 함수를 사용하면 데이터 멤버의 접근성에 대해 훨씬 정교한 제어를 할 수 있다

캡슐화: 함수를 통해서만 데이터 멤버에 접근할 수 있도록 구현해 두면, 데이터 멤버를 나중에 계산식으로 대체할 수 도 있다.


데이터 멤버를 함수 인터페이스 뒤에 감추게 되면 구현상의 융통성을 전부 누릴 수 있다.


사용자로부터 데이터 멤버를 숨기면, 클래스의 불변속성을 항상 유지하는 데 절대로 소홀해질 수 없다. C++ 세상에서 public이란 '캡슐화되지 않았다'는 뜻이며, 실질적인 측면에서 이야기할 때 '캡슐화되지 않았다'라는 말은 '바꿀 수 없다'라는 의미를 담는다. 어떤 데이터 멤버를 일단 public 혹은 protected로 선언했으며 사용자가 그것을 사용하기 시작했으면, 그때부터 그 멤버는 완전히 코 꿰인 것이다. 그 멤버에 대해 무엇을 바꾸기란 무척 힘들어진다. 캡슐화의 관전에서 쓸모 있는 접근 수준은 private(캡슐화 제공)와 private가 아닌 나머지(캡슐화 없음), 이렇게 둘뿐이다.


이것만은 잊지 말자!

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

- protected는 public보다 더 많이 '보호'받고 있는 것이 절대로 아니다.



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


객체 지향 법칙에 관련된 이야기를 찾아보면 데이터와 그 데이터를 기반으로 동작하는 함수는 한 데 묶여 있어야 하며, 메버 함수가 더 낫다고들 한다. 하지만 틀렸다. 분명히 객체 지향 법칙은 할 수 있는 만큼 데이터를 캡슐화하라고 주장하고 있다.


편의 함수 전체를 여러 개의 헤더 파일에(그러나 하나의 네임스페이스에) 나누어 놓으면 편의 함수 집합의 확장(extend)도 손쉬워진다.


이것만은 잊지 말자!

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



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


암시적 타입 변환에 대해 매개변수가 먹혀들려면 매개변수 리스트에 들어 있어야만 한다. 

"멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수이다" 어떤 클래스와 연관 관계를 맺어 놓고는 싶은데 멤버 함수이면 안 되는 (모든 인자에 대해 타입 변환이 필요하다든가 하는 이유로) 함수에 대해, 이런 것들은 프렌드로 만들어 버리면 다 해결된다고 가정하면 안된다. 프렌드 함수는 피할 수 있으면 피하자. 


이것만은 잊지 말자!

- 어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서)에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 한다.



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


C++는 클래스 템플릿에 대해서는 부분 특수화(partial specialization)를 허용하지만 함수 템플릿에 대해서는 허용하지 않도록 정해져 있다.

함수 템플릿을 '부분적으로 특수화'하고 싶을 때 흔히 취하는 방법은 그냥 오버로드 버전을 하나 추가하라.

 std의 영역을 침범하더라도 일단 컴파일까지는 거의 다 되고 실행도 된다. 그런데 실행되는 결과가 미정의 사항이라는 것이다.


멤버 swap을 호출하는 비멤버 swap을 선언해 놓되, 이 비멤버 함수를 std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지만 않으면 된다.


첫째, 표준에서 제공하는 swap이 여러분의 클래스 및 클래스 템플릿에 대해 납득할 만한 효율을 보이면, 그냥 아무것도 하지 말고 지내자.

둘째, 표준 swap의 효율이 기대한 만큼 충분하지 않다면, 1. 여러분의 타입으로 만들어진 두 객체의 값을 빛나게 빨리 맞바꾸는 함수를 swap이라는 이름으로 만들고, 이것을 public 멤버 함수로 둔다. 단 이 함수는 절대로 예외를 던져선 안된다. 2. 여러분의 클래스 혹은 템플릿이 들어 있는 네임스페이스와 같은 네임스프에스에 비멤버 swap을 만들어 넣는다.그리고 1번에서 만든 swap 멤버 함수를 이 비멤버 함수가 호출하도록 한다. 3. 새로운 클래스(클래스 템플릿이 아니라)를 만들고 있다면, 그 클래스에 대한 std::swap의 특수화 버전을 준비해 둔다. 

셋째, 사용자 입장에서 swap을 호출할 때, swap을 호출하는 함수가 std::swap을 볼 수 있도록 using 선언을 반드시 포함시킨다. 그 다음에 swap을 호출하되, 네임스페이스 한정자를 붙이지 않도록 한다.


이것만은 잊지 말자!

- std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공하자. 이 멤버 swap은 예외를 던지지 않도록 만들자

- 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공한다. 클래스(템플릿이 아닌)에 대해서는, std::swap도 특수화해 두자.

- 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap을 호출하자.

- 사용자 정의 타입에 대한 std템플릿을 완전 특수화하는 것은 가능하다. 그러나 std에 어떤 것이라도 새로 '추가'하려고 들지 마라.

728x90

댓글