C++에선 많은 부분이 인터페이스로 되어 있습니다. 함수도 인터페이스요, 클래스도 인터페이스요, 템플릿 또한 인터페이스입니다. 이상적으로는, 어떤 인터페이스를 어떻게 써 봤는데 결과 코드가 사용자가 생각한 대로 동작하지 않는다면 그 코드는 컴파일되지 않아야 맞습니다. 거꾸로 생각해서, 어떤 코드가 컴파일된다면 그 코드는 사용자가 원하는 대로 동작해야 할 것이고요. 따라서 여기엔 지침 하나가 존재합니다. 어떤 인터페이스를 설계하든지 막론하고 아마 가장 중요할 것 같은 지침, '제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게' 인터페이스를 설계하자는 것입니다.
'제대로 쓰기에 쉽고 엉터리로 쓰기에 어려운' 인터페이스를 개발하려면 우선 사용자가 저지를 만한 실수의 종류를 머리에 넣어두고 있어야 합니다. 예를 들어, 날짜를 나타내는 어떤 클래스에 넣을 생성자를 설계하고 있다고 가정합시다.
1 2 3 4 5 | class Date { public: Date(int month, int day, int year); ... }; | cs |
별 문제는 없을 것 같습니다.
그런데 여기에는 사용자가 쉽게 저지를 수 있는 오류가 두 개나 있습니다.
우선 매개변수의 전달 순서가 잘못될 여지가 있습니다.
Date d(30, 3, 1995); // "3, 30"이어야 하는데 "30, 3"을 넣었습니다.
두 번째는 월과 일에 해당하는 숫자가 오타 등의 이유로 허용되지 않은 숫자일 수 있다는 점입니다.
Date d(3, 40, 1995); // "3, 30"이어야 하는데 "3, 40"을 넣었습니다.
새로운 타입을 들여와 인터페이스를 강화하면 상당수의 사용자 실수를 막을 수 있습니다. 어처구니없는 코드가 컴파일되는 사태로부터 지켜주는 것이 바로 타입 시스템입니다. 지금의 경우, 일, 월, 연을 구분하는 간단한 랩퍼(wrapper) 타입을 각각 만들고 이 타입을 Date 생성자 안에 둘 수 있을 것입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | struct Day { explicit Day(int d) : val(d) {} int val; }; struct Month { explicit Month(int m) : val(m) {} int val; }; struct Year { explicit Year(int y) : val(y) {} int val; }; class Date { public: Date(const Month& m, const Day& d, const Year& y); ... }; Date d(30, 3, 1995); // 타입이 틀렸습니다. Date d(Day(30), Month(3), Year(1995)); // 타입이 틀렸습니다. Date d(Month(3), Day(30), Year(1995)); // 타입이 전부 맞았습니다. | cs |
일단 적절한 타입만 제대로 준비되어 있으면, 각 타입의 값에 제약을 가하더라도 괜찮은 경우가 생기게 됩니다. 예를 들어 월(月)이 가질 수 있는 유효한 값은 12개뿐이므로, Month 타입은 이 사실을 제약으로 사용할 수 있습니다. 한 가지 방법으로 월 표시 값을 나타내는 enum을 넣는 방법이 있는데, enum은 타입 안전성은 그리 믿음직하지 못합니다. 타입 안전성이 신경 쓰인다면 유효한 Month의 집합을 미리 정의해 두어도 괜찮습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class Month { public: static Month Jan() { return Month(1); } // 유효한 Month 값을 반환하는 함수들 static Month Feb() { return Month(2); } // 객체를 쓰지 않고 함수를 쓴 것은 ... // 비지역 정적 객체들 사이의 초기화 순서는 static Month Dec() { return Month(12); } // 정해져 있지 않기 때문입니다. ... // 다른 멤버 함수들 private: explicit Month(int m); // Month 값이 새로 생성되지 않도록 // 명시호출 생성자가 private 멤버입니다. ... // 월 표현을 위한 내부 데이터 }; Date d(Month::Mar(), Day(30), Year(1995)); | cs |
예상되는 사용자 실수를 막는 다른 방법으로는 어떤 타입이 제약을 부여하여 그 타입을 통해 할 수 있는 일들을 묶어 버리는 방법이 있습니다. 제약 부여 방법으로 아주 흔히 쓰이는 예가 'const 붙이기'입니다. 사실, 이 이야기는 '제대로 쓰기에 쉽고 엉터리에 쓰기에 어려운 타입 만들기'를 위한 또 하나의 일반적인 지침을 알려 주려고 일부러 끄집어낸 것입니다. 이름하여 '그렇게 하지 않을 번듯한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만들어라'입니다. int 등의 타입 정도는 사용자들이 그 성질을 이미 다 알고 있기 때문에, 여러분이 사용자를 위해 만드는 타입도 웬만하면 이들과 똑같이 동작하게 만드는 것이 좋습니다.
기본제공 타입과 쓸데없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서입니다. 제대로 쓰기에 괜찮은 인터페이스를 만들어 주는 요인 중에 일관성만큼 똑 부러지는 것이 별로 없으며, 편찮은 인터페이스를 더 나쁘게 만들어 버리는 요인 중에 비일관성을 따라오는 것이 거의 없습니다.
사용자 쪽에서 뭔가를 외워야 제대로 쓸 수 있는 인터페이스는 잘못 쓰기 쉽습니다.
언제라도 잊어버릴 수 있으니까요.
팩토리 함수를 예로 들어 보겠습니다.
Investment* createInvestment();
이 함수는 Investment 클래스 계통에 속해 있는 어떤 객체를 동적 할당하고 그 객체의 포인터를 반환하는 함수입니다.
이 함수를 사용할 때는, 자원 누출을 피하기 위해 createInvestment에서 얻어낸 포인터를 나중에라도 삭제해야 합니다. 그런데 이 점 때문에 사용자가 실수를 최소한 두 가지나 저지를 가능성이 만들어집니다. 포인터 삭제를 깜박 잊을 수 있고, 똑같은 포인터에 대해 delete가 두 번 이상 적용될 수 있거든요.
createInvestment의 반환 값을 auto_ptr이나 tr1::shared_ptr 등의 스마트 포인터에 저장한 후에 해당 포인터의 삭제 작업을 스마트 포인터에게 떠넘기는 방법이 있습니다. 하지만 이 스마트 포인터를 사용해야 한다는 사실을 사용자가 잊어버릴 경우에 대비해, 애초부터 팩토리 함수가 스마트 포인터를 반환하게 만드는 방법도 있습니다.
std::tr1::shared_ptr<Investment> createInvestment();
이렇게 해 두면, 이 함수의 반환 값은 tr1::shared_ptr에 넣어둘 수밖에 없을 뿐더러, 나중에 Investment 객체가 필요 없어졌을 때 이 객체를 삭제하는 것을 깜빡하고 넘어가는 불상사도 생기지 않을 것입니다.
createInvestment를 통해 얻은 Investment* 포인터를 직접 삭제하지 않게 하고, getRidOfInvestment라는 이름의 함수를 준비해서 여기에 넘기게 하면 어떨까요. 왠지 더 깔끔해 보이지만 이런 인터페이스는 되레 사용자 실수를 하나 더 열어놓는 결과를 가져옵니다. 자원 해제 메커니즘을 잘못 사용할 수가 있거든요(getRidOfInvestment를 잊어버리고 delete를 쓴다든지). createInvestment를 살짝 고쳐서, getRidOfInvestment가 삭제자로 묶인 tr1::shared_ptr을 반환하도록 구현해 둔다면 이런 문제는 발도 못 들여놓을 것입니다.
tr1::shared_ptr에는 두 개의 인자를 받는 생성자가 있습니다. 첫 번째 인자는 이 스마트 포인터로 관리할 실제 포인터이고, 두 번째 인자는 참조 카운트가 0이 될 때 호출될 삭제자입니다.
그러니까 tr1::shared_ptr이 널(null) 포인터를 물게 함과 동시에 삭제자로 getRidOfInvestment를 갖게 하는 방법으로 다음과 같은 코드를 쓰면 됩니다.
std::tr1::shared_ptr<Investment> pInv(static_cast<Investment*>(0), getRidOfInvestment);
tr1::shared_ptr 생성자의 첫 번째 매개변수는 포인터를 받아야 합니다.
따라서 캐스트를 적용하여 변환해줍니다.
createInvestment 함수에서 getRidOfInvestment를 삭제자로 갖는 tr1::shared_ptr을 반환하도록 구현한 코드는 다음과 비슷할 것입니다.
1 2 3 4 5 6 7 8 9 | std::tr1::shared_ptr<Investment> createInvestment() { std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestment); retVal = ...; // retVal은 실제 객체를 가리키도록 만듭니다. return retVal; } | cs |
tr1::shared_ptr에는 엄청 좋은 특징이 하나 있습니다. 바로 포인터별(per-pointer) 삭제자를 자동으로 씀으로써 사용자가 저지를 수 있는 또 하나의 잘못을 미연에 없애 준다는 점인데, 이 또 하나의 잘못이란 바로 '교차 DLL 문제(cross-DLL problem)'입니다. 이 문제가 생기는 경우가 언제냐 하면, 객체 생성 시에 어떤 동적 링크 라이브러리(dynamic linked library: DLL)의 new를 썼는데 그 객체를 삭제할 때는 이전의 DLL과 다른 DLL에 있는 delete를 썼을 경우입니다. 이렇게 new/delete 짝이 실행되는 DLL이 달라서 꼬이게 되면 대다수의 플랫폼에서 런타임 에러가 일어나지요.
그런데 tr1::shared_ptr은 이 문제를 피할 수 있습니다. 이 클래스의 기본 삭제자는 tr1::shared_ptr이 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 만들어져 있기 때문입니다. 예를 들어 Stock이라는 클래스가 Investment에서 파생된 클래스이고 createInvestment 함수가 아래와 같이 구현되어 있다고 가정합시다.
1 2 3 4 | std::tr1:shared_ptr<Investment> createInvestment() { return std::tr1::shared_ptr<Investment>(new Stock); } | cs |
이 함수가 반환하는 tr1::shared_ptr은 다른 DLL들 사이에 이리저리 넘겨지더라도 교차 DLL 문제를 걱정하지 않아도 됩니다.
Stock 객체의 참조 카운트가 0이 될 때 어떤 DLL의 delete를 사용해야 하는지를 꼭 붙들고 잊지 않습니다.
따라서 tr1::shared_ptr을 사용하면 사용자가 무심코 저지를 수 있는 실수 몇 가지를 쉽게 없앰으로써 좋은 인터페이스를 만드는 데 쉽게 다가갈 수 있습니다.
참고로, tr1::shared_ptr을 구현한 제품 중 가장 흔히 쓰이는 것은 부스트 라이브러리입니다.
좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵습니다.
인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.
인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있습니다.
사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있습니다.
tr1::shared_ptr은 사용자 정의 삭제자를 지원합니다.
이 특징 때문에 tr1::shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있습니다.