Item 18 : Make interfaces easy to use correctly and hard to use incorrectly
인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들자.
좋은 인터페이스는 올바르게 사용하기엔 쉽고 잘 못 사용하기엔 어려워야 한다.
모든 인터페이스에서 이러한 특성을 만족시켜야 한다.
인터페이스가 올바르게 사용되기 위해, 인터페이스는 일관되고 built-in-type과 호환되는 동작을 포함해야 한다.
에러 방지를 위한 방법에는 새로운 type 만들기, type에 대한 operation 제한, object value 제한, 클라이언트의 리소스 관리 책임 제거가 있다.
shared_ptr은 custom deleters를 지원한다.
이것은 교차 DLL문제를 방지하고 자동으로 Mutex를 잠금 해제하는데 사용할 수 있다.
Item 19 : Treat class design as type design
클래스 설계는 타입 설계와 똑같이 취급하자.
클래스 설계는 새로운 타입을 정의한다고 생각해야 한다.
좋은 타입은 자연스러운 문법, 직관적인 의미, 효율적인 구현을 가진다.
그렇다면, 어떻게 클래스를 효율적으로 디자인하는가?
모든 클래스는 다음의 질문들을 고려해야 한다.
1. 새로운 타입의 객체를 어떻게 만들고 삭제해야 하는가?
어떻게 하는가에 따라 클래스의 메모리 할당과 해제뿐만 아니라 생성자와 소멸자의 설계가 결정된다.
2. 객체 할당이 객체 초기화와 어떻게 다른가?
이 질문은 생성자와 대입 연산자의 동작과 차이점을 결정한다.
초기화와 할당을 헷갈리지 않아야 한다.
3. 새로운 타입의 객체가 passed by value로 전달되는 것은 어떤 의미가 있는가?
pass-by-value를 구현하는 것은 copy constructor이다.
4. 새로운 타입의 적합한 값에 대한 제약은 무엇인가?
클래스 멤버 데이터의 몇 가지 조합은 유효해야 한다.
불변 속성에 따라 멤버 함수안에서 해야 할 에러 체크를 결정한다.
불변 속성은 함수가 발생시키는 예외에도 영향을 끼친다.
5. 기존의 클래스 상속 계통망에 맞출 것인가?
이미 존재하는 클래스를 상속받아 새로운 클래스를 만들 때, 함수가 virtual인지 non-virtual인지의 여부가 제약의 가장 큰 요인이다.
만약 내 클래스를 다른 클래스가 상속받을 수 있다면, virtual 함수가 있는지에 대한 여부가 중요하다.
특히 소멸자의 virtual 여부가 중요하다.
6. 새로운 타입에 대한 어떤 종류의 타입 변환이 허용되는가?
만약, 암시적으로 변환하기를 원한다면, 타입 연산자 오버로딩이 필요하다.
만약, 명시적으로 변환하기를 원한다면, 변환을 위한 함수를 만들어야 한다.
명시적으로만 변환하기를 원한다면, 변환을 위한 함수만을 만들고, type conversion operator또는 non-explicit constructor를 만드는 것은 피해야 한다.
7. 새로운 타입에 대해 타당한 operator와 function은 무엇인가?
이 질문의 대답에 따라 클래스에 선언할 함수가 결정된다.
8. 어떤 표준 함수가 허용되지 않아야 하는가?
허용되지 않아야 하는 함수는 private으로 선언한다.
9. 누가 새로운 타입의 멤버에 접근할 수 있는가?
이 질문에 대한 대답을 통해, 멤버의 public, protected, private여부를 결정한다.
또한 어떤 클래스나 함수를 friend로 만들지 결정하는데 도움이 된다.
10. 새로운 타입의 선언되지 않은 인터페이스는 무엇인가?
새로운 타입이 제공할 보장이 무엇인가에 대한 질문이다.
보장할 수 있는 부분은 수행 성능 및 예외 안전성, 자원 사용(잠금과 동적 메모리)이다.
보장하겠다고 한 것은 클래스 구현에서 제약이 된다.
11. 얼마나 새로운 타입이 일반적인가?
아마도 정의한 타입은 타입 하나를 정의하는 것이 아닌, 동일 계열의 타입 전체일지도 모른다.
이 경우 새로운 클래스 템플릿을 정의해야 한다.
12. 이것이 너가 필요로 하는 타입인가?
새로운 derived class를 정의해서 기존의 클래스에 몇개의 함수만을 추가하는 경우, 아마도 기존의 클래스에 몇개의 멤버 함수를 정의함으로써 더 잘 목표에 달성할 수 있다.
Item 20 : Prefer pass-by-reference-to-const to pass-by-value
'값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다.
디폴트로, C++는 함수에서 값으로 객체를 전달한다.
따로 특정하지 않으면, 함수 파라미터는 실제 argument의 복사본으로 초기화되고 함수 호출자는 함수의 리턴 값으로 값의 복사본을 받는다.
복사본은 객체의 copy constructor에 의해 생성된다.
이러한 pass-by-value는 매우 비싼 operation이다.
bool validateStudent(Student s);
Student plato;
bool platoIsOk = validateStudent(plato);
위의 경우 Student copy constructor가 파라미터 s를 plato로부터 초기화하기 위해 호출된다.
그리고 validateStudent가 리턴되면 s는 삭제된다.
따라서, 이 함수의 파라미터 전달의 비용은 한 번의 Student copy constructor호출과 Student destructor호출이다.
bool validateStudent(const Student& s);
이 코드는 더 효율적이다.
어떤 객체도 생성되지 않았기 때문에, 생성자나 소멸자가 호출되지 않는다.
파라미터를 const로 전달하는 것은 중요하다.
기존 버전의 validateStudent는 파라미터를 값으로 전달하기 때문에 함수 내에서 s를 수정하더라도 원본은 변경되지 않는다.
이 경우 Student가 pass by reference로 전달되기 때문에, const로 선언하는 것은 중요하다.
그렇지 않으면 함수내에서 Student가 변경될 수 있다.
파라미터를 참조로 전달하는 것은 slicing problem(복사 손실 문제)을 피한다.
derived class의 객체가 base class의 객체로 전달되는 경우가 있는데, 이때 값으로 전달되면 base class의 copy constructor가 호출되고, derived class의 객체로 동작하게 해 주는 특징이 없어진다.
즉, 객체가 base class 객체가 된다.
컴파일러 밑을 살펴보면, reference가 포인터로 구현된다는 것을 찾을 수 있다.
따라서 무언가를 reference로 전달하는 것은 보통 포인터를 넘겨주는 것을 의미한다.
결과적으로, 만약 built-in-type의 객체를 가진다면 value를 전달하는 것이 더 효율적일 수 있다.
이것은 iterators와 STL의 객체에도 적용된다.
왜냐하면 이것은 value로 전달되도록 설계되어 있다.
반복자와 함수를 구현할 때는 복사 효율을 높이고 복사손실 문제를 피해야 함을 고려해야 한다.
타입의 크기가 작으면 그것이 user-defined이더라도 pass-by-value가 더 효율적일 것이라 생각할 수 있다.
그러나 객체의 크기가 작은것이 copy constructor가 비싸지 않다는 것을 의미하지는 않는다.
STL 컨테이너를 포함하여 많은 객체들은 한개의 포인터를 가지지만, 이 객체를 복사하기 위해서는 멤버가 가리키는 대상까지 복사하는 작업도 수행해야 한다.
작은 크기의 객체가 copy constructor의 비용도 싸더라도, 성능 이슈가 있다.
몇몇 컴파일러는 built-in과 user-defined 타입을 다르게 다룬다.
Item 21 : Don't try to return a reference when you must return an object.
함수에서 객체를 반환해야 할 경우 참조자를 반환하지 말자.
Item20에서 pass-by-reference를 사용하는 것이 좋다고 언급했다.
그러나, pass-by-reference를 사용하면 존재하지 않는 객체에 대한 참조를 전달하는 문제가 발생할 수 있다.
rational number에 대한 클래스의 두 rational을 곱하는 함수를 살펴보자.
class Rational{
public:
Rational(int numerator = 0, int denominator = 1);
...
private:
int n, d;
friend
const Rational operator*(const Rational& lhs, const Rational& rhs);
};
이 버전의 operator*는 객체를 value로 전달한다.
객체를 value로 전달하기 때문에 생성자와 소멸자를 호출하는 비용에 대해 걱정할 수 있다.
그러나 결과를 reference로 전달하는 경우, 문제가 발생한다.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
Rational result(lhs.n * rhs.n, lhs.d*rhs.d);
return result;
}
생성자와 소멸자 호출하는 비용을 아끼기 위해 reference로 전달하는데, 이 경우 새로운 Rational 객체를 생성하기 위한 생성자와 소멸자를 호출하게 된다.
또한, result는 local object이기 때문에, 함수가 종료되면 local object인 result도 없어진다.
따라서 이 버전의 operator*는 Rational에 대한 reference를 전달하지 않는다.
이 함수뿐만 아니라 어떤 함수든 local object에 대한 reference를 리턴하면 삭제된 객체를 반환하게 된다.
그러면, 객체를 heap에 생성하고 그에대한 reference를 반환하는 경우를 고려해보자.
Heap-based의 객체는 new를 사용해 생성된다.
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로 삭제를 해야 메모리 누수 문제가 발생하지 않는다.
Rational w, x, y, z;
w = x * y * z;
위 코드의 경우, new를 사용하여 Heap-based 객체를 생성하는 operator* 함수를 사용했다고 가정하자.
그러면 위 구문에서는 operator* 함수가 2번 호출되며, 따라서 두 개의 new로 초기화된 객체가 생성되고 이 객체들에 대한 delete도 호출되어야 한다.
그러나 이 경우 delete를 할 수 있는 합리적인 방법이 없다.
클라이언트는 operator*를 통해 반환받은 객체 뒤에 숨겨진 포인터를 얻을 수 있는 합리적인 방법이 없다.
따라서 메모리 누수가 발생한다.
하지만 stack과 heap에 대한 접근 둘 다 생성자를 호출해야 한다.
우리의 최초 목표는 생성자 호출을 피하는 것이었다.
단 한번의 생성자만이 호출되도록 static Rational object를 반환하도록 함수를 변경해보자.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
static Rational result;
result = ...;
return result;
}
하지만, 이 버전은 다음과 같은 경우 문제가 발생한다.
bool operator==(const Rational& lhs, const Rational& rhs);
Rational a,b,c,d;
if((a*b)==(c*d)){
do whatever's appropriate when the products are equal;
}else{
do whatever's appropriate when they're not;
}
이 경우 a,b,c,d의 값에 상관 없이 ((a*b)==(c*d))는 항상 true를 반환한다.
따라서 새 객체를 반환해야 하는 함수를 작성하는 올바른 방법은 해당 함수가 새 객체를 반환하도록 하는 것이다.
inline const Rational operator*(const Rational& lhs, const Rational& rhs)
{
return new Rational(lhs.n * rhs.n, lhs.d*rhs.d);
}
물론 반환 값을 생성하고 파괴하는 비용이 발생할 수 있지만, 장기적으로 보면 올바른 동작을 위한 작은 대가일 뿐이다.
정리하자면, 만약 한 개 이상의 함수를 통해 반환되는 객체가 필요한 경우, 포인터나 local stack object에 대한 reference, heap-allocated object에 대한 reference, local static object에 대한 포인터나 reference를 사용하지 말자.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] Chapter 5: Implementations(1) (0) | 2022.07.05 |
---|---|
[Effective C++] Chapter 4: Design and Declarations(2) (0) | 2022.07.05 |
[Effective C++] Chapter 3: Resource Management (0) | 2022.07.01 |
[Effective C++] Chapter 2: Constructors, Destructors, and Assignment Operators(2) (0) | 2022.06.30 |
[Effective C++] Chapter 2: Constructors, Destructors, and Assignment Operators(1) (0) | 2022.06.30 |