항목 18 : 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자
'제대로 쓰기에 쉽고 엉터리로 쓰기에 어려운' 인터페이스를 개발하려면 우선 사용자가 저지를 만한 실수의 종류를 머리에 넣어두고 있어야 합니다.
새로운 타입을 들여와 인터페이스를 강화하면 상당수의 사용자 실수를 막을 수 있습니다.
일단 적절한 타입만 제대로 준비되어 있으면, 각 타입의 값에 제약을 가하더라도 괜찮은 경우가 생기게 됩니다.
예를 들면 월이 가질 수 있는 유효한 값은 12개이므로, Month라는 타입은 이 사실을 제약으로 사용 할 수 있습니다.
예상되는 사용자 실수를 막는 다른 방법으로는 어떤 타입이 제약을 부여하여 그 타입을 통해 할 수 있는 일들을 묶어 버리는 방법이 있습니다. 제약 부여 방법으로 아주 흔히 쓰이는 예가 'const 붙이기'입니다.
1
|
if (a * b = c) . . . // 흑, 나는 원래 비교하려고 그랬던 건데!
|
사실, 이 이야기는 '제대로 쓰기에 쉽고 엉터리로 쓰기에 어려운 타입 만들기'를 위한 또 하나의 일반적인 지침을 쉽게 알려 주려고 일부러 끄집어낸 것입니다. 이름하여 '그렇게 하지 않을 번듯한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만들지어다'라고 하지요. int 등의 타입 정도는 사용자들이 그 성질을 이미 다 알고 있기 때문에, 여러분이 사용자를 위해 만드는 타입도 웬만하면 이들과 똑같이 동작하게 만드는 센스를 갖추어라 이겁니다.
기본제공 타입과 쓸데 없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서 입니다. 제대로 쓰기에 괜찮은 인터페이스를 만들어 주는 요인 중에 일관성만큼 똑 부러지는 것이 별로 없으며, 편찮은 인터페이스를 더 나쁘게 만들어 버리는 요인 중에 비일관성을 따라오는 것이 거의 없습니다.
사용자 쪽에서 뭔가를 외워야 제대로 쓸 수 있는 인터페이스는 잘못 쓰기 쉽습니다. 언제라도 잊어버릴 수 있으니까요.
항목 13에 나온 바 있는 팩토리 함수를 예로 들어 보겠습니다.
1
|
Investment* createInvestment();
|
이 함수를 사용할 때는, 자원 누출을 피하기 위해 createInvestment에서 얻어낸 포인터를 나중에라도 삭제해야 합니다. 그런데 이 점 때문에 사용자가 실수를 최소한 두 가지나 저지를 가능성이 만들어집니다. 포인터 삭제를 깜박 잊을 수 있고, 똑같은 포인터에 대해 delete가 두 번 이상 적용될 수 있거든요.
그래서 항목 13의 이후를 더 읽어 보시면 createInvestment의 반환 값을 auto_ptr이나 tr1::shared_ptr 등의 스마트 포인터에 저장한 후에 해당 포인터의 삭제 작업을 스마트 포인터에게 떠넘기는 방법을 확인할 수 있을 것입니다.
하지만 이 스마트 포인터를 사용해야 한다는 사실을 잊을수 가 있기 때문에, 애초부터 팩토리 함수가 스마트 포인터를 반환하게 만드는 방법을 사용합니다.
1
|
std::tr1::shared_ptr<Investment> createInvestment();
|
이렇게 해 두면, 이 함수의 반환 값은 tr1::shared_ptr에 넣어둘 수밖에 없을 뿐더러, 나중에 Investment 객체가 필요 없어졌을 때 이 객체를 삭제하는 것을 깜빡하고 넘어가는 불상사도 생기지 않을 것입니다.
이런 가정도 한번 해 봅시다. createInvestment를 통해 얻은 Investmet* 포인터를 직접 삭제하지 않게 하고 getRidOfInvestment라는 이름의 함수를 준비해서 여기에 넘기게 하면 어떨까요. 왠지 더 깔끔해 보이지만 이런 인터페이스는 되레 사용자 실수를 하나 더 열어놓는 결과를 가져옵니다. 자원 해제 메커니즘을 잘못 사용할 수가 있거든요.
createInvestment를 살짝 고쳐서, getRidOfInvestment를 갖게 하는 방법으로 다음과 같은 코드를 쓰면 안 될까하는 생각이 듭니다.
tr1::shared_ptr에는 두 개의 인자를 받는 생성자가 있습니다. 첫 번째 인자는 이 스마트 포인터로 관리할 실제 포인터이고, 두 번째 인자는 참조 카운트가 0이 될 때 호출될 삭제자입니다. 그러니까 tr1::shared_ptr이 널 포인터를 물게 함과 동시에 삭제자로 getRidOfInvestment를 갖게 하는 방법으로 다음과 같은 코드를 쓰면 안 될까 하는 생각이 듭니다.
1
|
std::tr1::shared_ptr<Investment> pInv(0, getRidOfInvestment); // 이렇게 해서 사용자 정의 삭제자를 가진 널 shared_ptr을 생성했으면 좋겠습니다. 그런데 컴파일이 안 되니 난감할 뿐이죠.
|
이것은 제대로 쓴 C++ 코드가 아닙니다. tr1::shared_ptr의 '그' 생성자는 첫 번째 매개변수로 포인터를 받아야 합니다. 그런데 0은 포인터가 아니라 int이죠. 아, 물론 0은 포인터로 변환할 수 있지만 지금의 경우에는 이것만으로는 부족합니다.
tr1::shared_ptr이 요구하는 포인터는 Investment* 타입의 실제 포인터이기 때문입니다. 그래서 캐스트를 적용하여 사태를 해결합니다.
1
|
std::tr1::shared_ptr<Investment> pInv(static_cast<Investment*>(0), getRidOfInvestment); // getRidOfInvestment를 삭제자로 갖는 null shared_ptr을 생성합니다.
|
이제는 createInvestment 함수에는 getRidOfInvestment를 삭제자로 갖는 tr1::shared_ptr을 반환하도록 구현하는 방법이 어렴풋이 정리되겠죠? 아마 다음의 코드와 비슷할 것입니다.
1
2
3
4
5
6
7
8
|
std::tr1::shared_ptr<Investment> createInvestment()
{
std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestment);
retVal = ...; // retVal은 실제 객체를 가리키도록 만듭니다.
return retVal;
}
Colored by Color Scripter
|
눈치 채신 분도 있겠지만, retVal로 관리할 실제 객체의 포인터를 결정하는 시점이 retVal을 생성하는 시점보다 앞설 수 있으면, 위의 코드처럼 retVal을 null로 초기화하고 나서 나중에 대입하는 방법보다 실제 객체의 포인터를 바로 retVal의 생성자에 넘겨버리는 게 더 낫습니다.
tr1::shared_ptr에는 엄청 좋은 특징이 하나 있습니다. 바로 포인터별 삭제자를 자동으로 씀으로써 사용자가 저지를 수 있는 또 하나의 잘못을 미연에 없애 준다는 점인데, 이 또 하나의 잘못이란 바로 교차 DLL 문제입니다. 이 문제가 생기는 경우가 언제냐 하면, 객체 생성 시에 어떤 동적 링크 라이브러리의 new를 썼는데 그 객체를 삭제할 때는 이전의 DLL과 다른 DLL에 있는 delete를 썼을 경우입니다. 이렇게 new/delete 짝이 실행되는 DLL이 달라서 꼬이게 되면 대다수의 플랫폼에서 런타임 에러가 일어나지요. 그런데 tr1::shared_ptr은 이 문제를 피할 수 있습니다. 이 클래스의 기본 삭제자는 tr1::shared_ptr이 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 만들어져 있기 때문입니다. 무슨 뜻이냐 하면, 예를 들어 Stock 클래스가 Investment에서 파생된 클래스이고 create-Investment 함수가 아래와 같이 구현되어 있다고 할 때,
1
2
3
4
|
std::tr1::shared_ptr<Investment> createInvestment()
{
return std::str1::shared_ptr<Investment>(new Stock);
}
Colored by Color Scripter
|
이 함수가 반환하는 tr1::shared_ptr은 다른 DLL들 사이에 이리저리 넘겨지더라도 교차 DLL 문제를 걱정하지 않아도 된다는 뜻입니다. Stock 객체를 가리키는 tr1::shared_ptr은 그 Stock 객체의 참조 카운트가 0이 될 때 어떤 DLL의 delete를 사용해야 하는지를 꼭 붙들고 잊지 않습니다.
꼭 잊지 말아야 할 것!
1. 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵습니다. 인터페이스를 만들때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.
2. 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있습니다.
3. 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있습니다.
4. tr1::shared_ptr은 사용자 정의 삭제자를 지원합니다. 이 특징 때문에 tr1::shared_ptr은 교차 DLL 문제를 막아주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있습니다.
'언어 > C++' 카테고리의 다른 글
[Effective C++] '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다 (0) | 2020.01.20 |
---|---|
[Effective C++] 클래스 설계는 타입 설계와 똑같이 취급하자 (0) | 2020.01.19 |
[Effective C++] new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자 (0) | 2020.01.17 |
[Effective C++] new 및 delete를 사용할 때는 형태를 반드시 맞추자 (0) | 2020.01.16 |
[Effective C++] 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자 (0) | 2020.01.15 |