Named Constructor Idiom?
다들 아시다시피 C++의 생성자는 특별한 이름을 가질 수 없습니다. 그런데 가끔씩 가독성을 위해서 Self-descriptive한 이름의 생성자를 만들고 싶을 때가 있습니다. 아니면, 문법적 한계 때문에 꼭 필요한 경우도 생각해볼 수 있죠. 이럴 때 사용할 수 있는 것이 Named constructor idiom 입니다.
첫 번째 예로는 Named constructor idiom을 설명할 때 자주 등장하는 단골 예제를 가져와보겠습니다.
Self-descriptive한 이름 & 문법 극복
class Point
{
public:
Point(float x, float y); // 직교 좌표계(데카르트 좌표계)
Point(float radius, float angle); // 극 좌표계
};
위와 같은 코드가 있습니다. 특정 위치의 점을 나타내는 Point 클래스인데요. 두 개의 생성자를 이용해 직교 좌표계, 극 좌표계 두 가지를 사용할 수 있도록 하고 싶습니다. 얼핏 보면 문제 없어보이지만, 위와 같은 코드는 사용할 수 없습니다. 두 생성자가 같은 타입의 인자를 사용하기 때문입니다.
int main()
{
Point p{5.7, 1.2}; // 어떤 좌표계를 사용해 생성하는 걸까?
...
}
이럴 때 사용할 수 있는 것이 Named constructor idiom 입니다. 이름은 거창하지만 내용은 간단합니다. 생성하고자 하는 객체를 반환하는 static 함수를 만드는 것이죠. 코드로 보겠습니다.
class Point
{
public:
Point(float x, float y) : x_(x), y_(y) {}
static Point Rectangular(float x, float y)
{
return {x, y};
}
static Point Polar(float radius, float angle)
{
return {radius * std::cos(angle), radius * std::sin(angle)};
}
private:
float x_;
float y_;
};
간단하죠? Rectangular 함수를 사용하면 직교 좌표계의 x, y 좌표로 Point 객체를 생성할 수 있고, Polar 함수를 사용하면 극 좌표계의 반지름, 각도 값으로 Point 객체를 생성할 수 있습니다. 아래 처럼요.
int main()
{
Point p1 = Point::Rectangular(1.3, 2.5);
Point p2 = Point::Polar(4.0, 0.5);
...
}
보이다시피 객체를 생성하는 데 두 가지 다른 방법을 사용할 수 있을 뿐더러, Self-descriptive한 이름을 가지기 때문에 가독성도 높아졌죠. 물론, 스타일에 따라 CreateWithRectangularCoordinate() 같은 긴 이름을 사용하는게 더 나았을 수는 있겠지만요.
개선점
하지만 위의 코드도 완벽한 상태는 아닙니다. 왜냐하면 기존의 생성자를 통해서도 객체를 생성할 수 있기 때문이죠. 생성자 대신 static 함수들만을 이용해 객체를 생성하도록 강제하는게 바람직하겠죠. 그러기 위해서는 생성자의 접근 지정자를 protected 혹은 private로 지정해주면 됩니다. 아래가 일부를 생략한 최종 코드입니다.
class Point
{
public:
static Point Rectangular(float x, float y);
static Point Polar(float radius, float angle);
private:
Point(float x, float y) : x_(x), y_(y) {}
float x_;
float y_;
};
다른 예
물론 다른 목적으로도 사용될 수 있습니다. 이번에는 하나의 구체적인 시나리오를 들어보겠습니다.
- 구조체 Condition의 값에 따라, 다른 상태를 가지는 객체 Object를 생성하고싶다.
- Condition 구조체의 특정 값에 대해서는, 객체 생성이 실패하도록 하고 싶다.
- 그러나 생성자에서 예외를 던지는 형태로 오류 처리를 하고싶지는 않다.
위의 조건들을 충족하기 위해 Named constructor idiom을 사용할 수 있습니다. 2, 3번 조건을 충족하기 위해, 생성자 대신 optional 혹은 unique_ptr을 반환하는 static 함수를 사용하는 방식으로 말이죠. 코드를 보겠습니다.
class Object
{
public:
static std::unique_ptr<Object> CreateWithCondition(const Condition& c)
{
if (c.value == 1)
{
return std::make_unique<Object>(COLOR_WHITE, RECTANGLE);
}
else
{
return {};
}
}
private:
Object(Color color, Shape shape);
};
이러면 1, 2, 3번 조건을 충족하면서 아래처럼 사용할 수 있겠죠.
...
auto obj = Object::CreateWithCondition(c);
if (!obj)
{
// ERROR!
}
DoSomethingWithObject(obj);
...
문제점
하지만 또 문제가 있습니다. 위처럼 static 함수에서 std::make_unique를 이용해 유니크 포인터를 생성해 반환하려는 경우 오류가 발생하는 것입니다. private 접근 지정자를 가진 생성자에 접근할 수 없기 때문이죠. 어떻게 해결할 수 있을까요?
이 문제를 해결하는 아주 흥미로운! 두 가지 트릭을 소개하겠습니다.
트릭 1: private 구조체 트릭
class Object
{
struct private_struct {};
public:
Object(private_struct p, Color c, Shape s);
static std::unique_ptr<Object> CreateWithCondition(const Condition& c)
{
...
return std::make_unique<Object>(private_struct{}, COLOR_WHITE, RECTANGLE);
...
}
};
문제가 해결됐습니다! 트릭이 이해 되시나요? 위처럼 코드를 작성하면, static 함수는 Object 클래스의 멤버이기 때문에 private_struct 구조체에 접근 가능하고, 생성자의 접근 지정자는 public이니 문제없이 std::make_unique로 유니크 포인터를 생성할 수 있습니다!
반면, 외부에서는 Object 클래스의 private_struct 구조체에 접근할 수 없으므로 public 접근 지정자로 공개된 생성자가 있어도 사용을 할 수 없게 되는 것이죠. 흥미롭지 않나요?
트릭 2: 상속
두 번째 트릭은 비교적 간단합니다. 바로 상속을 이용하는 것이죠. 개인적으로는 상속이 가상 함수나 소멸자 등으로 인해 모호한 오류들을 발생시킬 수 있다는 점에서 트릭에 이용하는 것이 꺼림찍합니다. 하지만 첫 번째 트릭에 비해 외적으로 깔끔하고, static 함수 내부에서 해결할 수 있다는 장점이 있어 어쨌거나 사용해볼만 하죠.
class Object
{
public:
static std::unique_ptr<Object> CreateWithCondition(const Condition& c)
{
...
class Trick : public Object { using Object::Object; };
return std::make_unique<Trick>(COLOR_WHITE, RECTANGLE);
...
}
private:
Object(Color c, Shape s);
};
이렇게 몇 가지 예로 Named constructor idiom에 대해 알아보았습니다. 최근에 실제 업무에서도 위와 같은 코드들을 사용할 일이 생겨서 한 번 정리해봤는데, 흥미로웠나요?
C++을 사용하다가 무언가 막혀서 검색해보면 위와 같은 트릭이나 템플릿을 이용해 흑마술을 부리는 것을 자주 볼 수 있는데, 이것도 C++만의 매력이 아닐까(?) 싶습니다. ^^;
인용 코드 출처: C++FAQ