Today I Learn

[TIL][C++] 250602 MMO 서버 개발 30일차: T()는 기본 타입에도 적용할 수 있는 문법인가?, 생성자 호출이 없는 std::optional (C++17~) 등등…

pledge24 2025. 6. 3. 00:01
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

T()는 기본 타입에도 적용할 수 있는 문법인가?

  • ServerCore 부분에 작성되어 있던 LockQueue를 다시 보고 있던 와중, Pop() 함수의 리턴값이 T()인 것을 발견했다.
  • 클래스는 생성자가 있으므로, T가 클래스 타입이면 T()와 같이 적어도 문제가 없겠지만, int와 float와 같은 기본 타입에 ()를 붙여 반환하는 것이 가능한 지 의문이 들었다.
  • 생성자도 없는 기본 타입을 저장하는 LockQueue라면 문제없이 돌아갈까?

기본 타입에서도 T()는 유효하다.

  • 찾아보니 C++에서 int, float와 같은 기본 타입들은 모두 값 초기화(value initalization)를 지원하며, T()와 같은 방식으로 초기화한다고 한다. 그래서, 아래와 같은 코드로 기본 타입을 초기화하는 것이 가능하다.
int x = int();        // 0
float y = float();    // 0.0f
double z = double();  // 0.0
char c = char();      // '\\0' (null character)
bool b = bool();      // false
  • 심지어, 포인터 타입도 동일하게 값 초기화를 지원한다고 한다.
int* ptr = int*();    // nullptr
char* cptr = char*(); // nullptr
  • 결론적으로 아래와 같이 템플릿 함수가 적용되었을 때 LockQueue의 Pop()의 리턴값이 T()이라도, 기본 타입에서도 유효하게 사용할 수 있다.
// LockQueue의 Pop 멤버 함수 정의
T Pop()
{
	if (_items.empty())
		return T();

	T ret = _items.front(); _items.pop();
	
	return ret;
}

// logic...
LockQueue<int> lq;
lq.Push(10);
int elem1 = lq.Pop(); // 10
int elem2 = lq.Pop(); // 0(int()의 반환값)

Pop()이 비었을 때 생성자를 호출해 반환하는 것이 최선일까?

  • 위에서 다룬 LockQueue의 Pop() 함수에 대해 조금 더 다뤄보자면, 사실 T()와 같은 반환값은 그리 좋은 선택이 아닐 수 있다. 왜냐하면 데이터가 없을 때 빈 객체를 만들어서 반환하면 1) 불필요한 생성자 호출 비용이 들고, 2) 기본값이 들어있는 실제 데이터와 구분을 할 수 없어지기 때문이다.
  • 위와 같은 방법을 해결하기 위해, 생각해 볼 수 있는 해결책 중 naive 한 방법은 데이터가 들어있는지 여부를 bool 타입으로 관리하고 이를 pair로 한 쌍으로 묶어 반환하는 것이다.
pair<bool, T> Pop()
{
	if (_items.empty())
		return make_pair(false, T());

	T ret = _items.front(); _items.pop();
	
	return make_pair(true, ret);
}
  • 위와 같이 Pop() 함수를 작성하면, bool값을 보고 해당 데이터가 존재하는지 판단할 수 있을 것이다. 하지만 1) 반환값이 pair 하는 점이 사용하기 불편하고, 2) 불필요한 생성자 호출 비용이 발생한다는 점은 여전히 존재한다는 단점이 있다.

생성자 호출이 없는 std::optional (C++17~)

  • c++17에 std::optional이라는 기능이 등장했다. std::optional은 “생성자 호출 없이” 빈 깡통을 리턴할 수 있다.
  • std::optional을 사용하면 위에서 발생했던 두 가지 문제, 1) 불필요한 생성자 호출, 2) 빈 깡통과 기본값으로 들어있는 데이터 구분 불가 문제를 한 번에 해결해 준다.
#include <optional> // std::optional 정의 헤더
...
std::optional<T> Pop()
{
	if (_items.empty())
		return std::nullopt;

	std::optional<T> ret = _items.front(); _items.pop();
	
	return ret;
}
  • 위와 같이 optional의 템플릿 인자로 T를 넣어주고, 기존 T타입의 변수를 대입해주기만 하면 된다. (사용 방법이 쉽다)

꺼내 쓰는 쪽은 어떻게 해야 하는가?

// logic...
LockQueue<int> lq;
lq.Push(10);

// lq.Pop().has_value() == lq.Pop()
if(std::optional<int> it = lq.Pop()){
	int data = *it; // 예외 확인 X
	// int data = it.value(); // 예외 확인 O
}
else{
// 데이터 없음(nullopt 반환됨)
}

  • 꺼내 쓸 때엔 위와 같이 사용하면 된다.
  • optional 객체는 그 자체만으로 데이터가 들어있는지 유무를 판단할 수 있다. 따라서 has_value 멤버 함수를 호출해 데이터가 존재하는지 확인하지 않아도, 객체 자체를 통해 데이터의 존재 유무를 확인할 수 있다.
// 이 둘은 같은 표현이다.
if(lq.Pop().has_value()){...}
if(lq.Pop()){...}
  • 반환된 optional 객체에서 저장되어 있는 값을 꺼내쓸 때엔 ‘*’ 연산자 사용 또는 value() 함수 호출을 하면 된다.
  • 이 둘의 차이점은 optional 객체가 빈 깡통일 때(nullopt일 때) 예외 체크를 하는지 차이다.
    • ‘’는 예외 체크를 하지 않기 때문에, 빈 깡통인 optional 객체에 ‘’을 붙이면 그대로 크래시가 난다. (하지만 빠르다)
    • value()는 예외 체크를 하기 때문에, 빈 깡통인 객체에 사용하면, 크래시 대신 예외를 던진다. (예외: std::bad_optional_access)
  • 어찌 되었든 빈 깡통에서 데이터를 꺼내는 상황은 비정상적인 상황이므로, 발생하면 안 되는 상황인 건 똑같다. 따라서, 위와 같이 optional 객체가 true일 때만 값을 꺼내 쓰는 패턴이 정석적인 방법이다.
if(std::optional<int> it = lq.Pop()){
	int data = *it // optional 객체가 true => 값 존재 확인됨
}

pair <bool, T> vs optional <T>

  • 거두절미하고 pair 방식보다 optional이 훨씬 깔끔하고 성능도 좋다. 둘 다 어찌 되었든 실제 데이터를 감싼 형태라 지저분하게 데이터를 꺼내 쓴다면(pair는 p.second, optional객체는 *it), optional을 안 쓸 일은 없다.
  • 다만 버전이 C++17 이상이어야지 사용할 수 있는지라 버전이 낮다면 pair 방식을 사용할 수밖에 없을 것이다(선택지가 지워진 건데 뭐…)

T() vs optional <T>

  • 다시 돌아와서 “그럼 T()보다 optional <T>가 항상 좋은 건가?’라는 질문을 해보면 그건 아닌 것 같다.
  • optional객체는 어찌 되었든 wrapper 클래스이므로, 반환값 자체가 데이터가 되진 않는다. 데이터를 꺼내 쓰는 작업이 반드시 필요하고, 이는 사용하는 입장에서 조금 더러울 수 있다.
  • 반면, T()는 생성자 호출 비용을 감안하는 대신, (게다가 기본값 구분 가능 여부도 희생하는 대신) 사용하는 입장에서 곧바로 사용할 수 있다는 점은 큰 장점을 가진다.
  • 돌고 돌아서, 지금 생각해 보면 T()가 여러모로 optional객체보다 크게 떨어지는 방식은 아니라고 생각이 든다. 그래서 그때그때 상황 봐서 섞어 쓰는 게 낫다고 본다(결국 본인은 T()를 쓰기로 했다)