전문가를 위한 C++/ C++의 까다롭고 유별난 부분

Contents

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

레퍼런스

C++에서 레퍼런스란 일종의 변수에 대한 앨리어스(alias, 별칭)이다. 레퍼런스를 이용해서 수정한 내용은 그 레퍼런스가 가리키는 변수의 값에 그대로 반영된다. 레퍼런스는 변수의 주소를 가져오거나 변수에 대한 역참조 연산을 수행하는 작업을 자동으로 처리해주는 특수한 포인터라고 볼 수 있다. 또는 변수에 대한 다른 이름(별칭)이라 생각해도 된다.

변수를 레퍼런스로 만들 수 있고, 클래스의 데이터 멤버를 레퍼런스로 만들 수 있고 함수나 메서드가 레퍼런스를 매개변수로 받거나 레퍼런스를 리턴하게 만들 수 있다.

레퍼런스 변수

레퍼런스 변수는 반드시 생성하자마자 초기화 해야 한다.

int x = 3;
int& xRef = x;

x 변수에 대한 대입문 바로 뒤에 나온 xRef는 x에 대한 또 다른 이름이다. xRef를 사용하는 것은 x를 사용하는 것과 같다. xRef에 어떤 값을 대입하면 x의 값도 바뀐다.

정수 리터럴처럼 이름 없는 값에 대해서는 레퍼런스를 생성할 수 없다. 단 const 값에 대해서는 레퍼런스를 생성할 수 있다.

int& unnamedRef1 = 5;  // 컴파일 에러 발생
const int& unnamedRef2 = 5; // 정상 작동

임시 객체도 마찬가지인데, 임시 객체에 대해 non-const 레퍼런스는 만들 수 있지만 const 레퍼런스는 얼마든지 만들 수 있다. 예컨대 다음과 같이 std::string 객체를 리턴하는 함수를 살펴보자.

std::string getString() { return "Hello world!"; }

getString()을 호출한 결과를 const 레퍼런스에 담을 수 있다. 그러면 이 레퍼런스가 스코프를 벗어나기 전까지 std::string 객체를 계속 가리킬 수 있다.

std::string& string1 = getString();  // 컴파일 에러 발생
const std::string& string2 = getString(); // 정상 작동

레퍼런스 대상 변경하기

레퍼런스는 처음 초기화할 때 지정한 변수만 가리킨다. 레퍼런스는 한 번 생성되고 나면 가리키는 대상을 바꿀 수 없다.

이 규칙 때문에 문법이 좀 헷갈리는데 레퍼런스를 선언할 때 어떤 변수를 ‘대입’ 하면 레퍼런스는 그 변수를 가리킨다. 하지만 이렇게 한 번 선언된 레퍼런스에 다른 변수를 대입하면 레퍼런스가 가리키는 대상이 바뀌는 것이 아니라 레퍼런스가 원래 가리키던 변수의 값이 새로 대입한 변수의 값으로 바뀌게 된다.

int x = 3, y = 4;
int& xRef = x;
xRef = y; //xRef가 y를 가리키는 것이 아니라 x의 값을 4로 변경한다.

여기서 y의 주소를 대입하면 가리키는 대상을 바꿀 수 있다고 생각할 수 있다.

xRef = &y;  // 컴파일 에러 발생

하지만 이렇게 작성하면 컴파일 에러가 발생한다. y의 주소는 포인터이지만 xRef는 포인터에 대한 레퍼런스가 아닌 int에 대한 레퍼런스이기 때문이다.

(이하 생략)

포인터에 대한 레퍼런스와 레퍼런스에 대한 포인터

레퍼런스는 모든 타입에 대해 만들 수 있는데, 심지어 포인터 타입을 가리키는 레퍼런스도 만들 수 있다. 예컨대 int 포인터를 가리키는 레퍼런스를 다음과 같이 만들 수 있다.

int* intP;
int*& ptrRef = intP;
ptrRef = new int;
*ptrRef = 5;

이렇게 *와 &가 연달아 붙은 표현이 생소하겠지만 의미는 간단하다. ptrRef는 intp에 대한 레퍼런스고, intP는 int에 대한 포인터다. ptrRef를 수정하면 intP가 바뀐다.

레퍼런스가 가져온 주소는 그 레퍼런스가 가리키는 변수의 주소와 같다.

int x = 3;
int& xRef = x;
int* xPtr = &xRef; // 레퍼런스의 주소는 값에 대한 포인터와 같다.
*Ptr = 100;

이 코드는 x에 대한 레퍼런스 주소를 가져와서 xPtr가 x를 가리키도록 설정한다. 그래서 *xPtr에 100을 대입하면 x의 값이 100으로 바뀐다.

그런데 ‘xPtr == xRef’라는 비교 연산을 수행하면 서로 타입이 다르다는 컴파일 에러가 발생한다. xPtr는 int에 대한 포인터 타입이고 xRef는 int에 대한 레퍼런스 타입이기 때문이다.. 따라서 ‘xPtr == &xRef’나 ‘xPtr == &x’와 같이 작성해야 한다.

마지막으로 주의해야 할 점은 레퍼런스에 대한 레퍼런스를 선언할 수 없다는 것이다. 예컨대 ‘int& &’나 ‘int&*’와 같이 선언할 수 없다.

레퍼런스 데이터 멤버

클래스의 데이터 멤버를 레퍼런스 타입으로 정의할 수 있다. 레퍼런스는 어떤 변수를 가리키지 않고서는 존재할 수 없다. 따라서 레퍼런스 데이터 멤버는 반드시 생성자의 본문이 아닌 생성자 이니셜라이저에서 초기화해야 한다.

class MyClass
{
public:
MyClass(int& ref) : mRef(ref) {}
private:
int& mRef;
}

레퍼런스 매개변수

레퍼런스 변수나 레퍼런스 데이터 멤버를 별도로 선언해서 사용하는 일은 많지 않다. 레퍼런스는 주로 함수나 메서드의 매개변수로 많이 사용한다.

(이하 설명 생략)

포인터를 레퍼런스로 전달하기

매개변수가 레퍼런스 타입인 함수나 메서드에 포인터를 전달하려면 좀 난감해진다. 이때는 포인터를 역참조해서 전달하면 포인터를 레퍼런스로 ‘변환’ 할 수 있다. 포인터가 가리키는 값을 가져와서 레퍼런스 매개변수를 초기화하기 때문이다. 예컨대 swap()을 호출할 때 포인터를 전달하려면 다음과 같이 작성한다.

int x = 5, y = 6;
int *xp = &x, *yp = &y;
swap(*xp, *yp);

레퍼런스 전달 방식과 값 전달 방식

레퍼런스 전달 방식은 함수나 메서드 안에서 인수로 전달한 값을 수정하고 싶을 때 주로 사용하는데, 이 경우 말고도 레퍼런스 전달 방식이 필요할 때가 있다. 레퍼런스로 전달하면 인수에 대한 복제본을 만들지 않기 때문에 다음과 같은 두 가지 장점이 있다.

  1. 효율성: 크기가 큰 객체나 stuct는 복제 오버헤드가 크다. 레퍼런스 전달 방식을 사용하면 객체나 struct에 대한 레퍼런스만 함수에 전달한다.
  2. 정확성: 값 전달 방식을 지원하지 않는 객체가 있다. 지원하더라도 깊은 복제가 적용되지 않을 수 있다.

이런 장점을 최대한 활용하고 원본 객체는 수정할 수 없게 만들려면 매개변수를 const 레퍼런스로 선언하면 된다.

레퍼런스 리턴값

함수나 메서드의 리턴값도 레퍼런스 타입으로 지정할 수 있다. 이렇게 하는 주된 이유는 효율성 때문이다. 객체 전체를 리턴하지 않고 객체에 대한 레퍼런스만 리턴하면 복제 연산을 줄일 수 있다. 물론 함수 종료 후에도 계속 남아 있는 객체에 대해서만 레퍼런스로 리턴할 수 있다.

rvalue 레퍼런스

(생략)

레퍼런스와 포인터의 선택 기준

레퍼런스로 할 수 있는 일을 포인터로 할 수 있기 때문에 C++에서 굳이 레퍼런스를 제공할 이유가 없다고 생각할 수도 있다. 예컨대 swap()을 다음과 같이 포인터로 구현해도 된다.

void swap(int* first, int* second)
{
int temp = *first;
*first = *second;
*second = temp;
}

하지만 이렇게 하면 코드가 복잡해진다. 레퍼런스를 사용하면 코드를 깔끔하게 읽기 쉽게 작성할 수 있고, 포인터보다 훨씬 안전하다. 레퍼런스의 값은 널이 될 수 없고, 레퍼런스를 명싲거으로 역참조 할 수도 없다. 그래서 포인터처럼 역참조 과정에서 에러가 발생할 가능성도 없다. 

단, 포인터가 하나도 없을 때만 레퍼런스가 더 안전하다고 할 수 있다. 예컨대 다음과 같이 int에 대한 레퍼런스를 인수로 받는 함수를 살펴보자.

void refcall(int& t) { ++t; }

임의의 메모리를 가리키도록 초기화한 포인터를 하나 만들자. 그리고 다음 코드처럼 이 포인터를 역참조해서 refcall()의 레퍼런스 타입 인수로 전달해 보자. 그러면 컴파일 에러는 발생하지 않지만 실행 과정에서 무슨 일이 벌어질지 예측할 수 없다. 실행하자마자 프로그램이 뻗어버릴 수 있다.

int* ptr = (int*)8;
refcall(*ptr);

포인터를 사용한 코드는 거의 대부분 레퍼런스로 바꿀 수 있다. 심지어 객체에 대한 레퍼런스는 객체에 대한 포인터처럼 다형성도 지원한다. 하지만 반드시 포인터를 사용해야 하는 경우가 있다. 대표적인 예로 가리키는 위치를 변경해야 할 때가 있다. 레퍼런스 타입 변수는 한 번 초기화되고 나면 그 변수가 가리키는 주솟값을 바꿀 수 없다. 예컨대 동적 할당 메모리의 주소는 레퍼런스가 아닌 포인터에 저장해야 한다. 또 다른 예로 주솟값이 nullptr가 될 수도 있는 optional 타입은 반드시 포인터를 사용해야 한다. 또한 컨테이너에 다형성 타입을 저장할 때도 포인터를 사용해야 한다.

매개변수나 리턴값을 포인터와 레퍼런스 중 어느 것으로 표현하는 것이 적합한지 판단하는 한 가지 방법은 메모리의 소유권이 어디에 있는지 따져보는 것이다. 메모리의 소유권이 변수를 받는 코드에 있으면 객체에 대한 메모리르 해제하는 책임은 그 코드에 있다. 따라서 객체를 포인터, 가능하면 스마트 포인터를 표현한다. 소유권을 이전할 필요가 있다면 항상 스마트 포인터를 사용하는 것이 좋다. 반면 메모리 소유권이 변수를 받는 코드에 없어서 메모리를 해제할 일이 없다면 레퍼런스로 전달한다.

Note) 포인터보다 레퍼런스를 사용하는 것이 좋다. 레퍼런스를 사용할 수 없을 때만 포인터를 사용한다.

(예시 코드 생략)

하지만 여기서 구현한 것처럼 결과를 매개변수로 전달하는 방식은 가급적 사용하지 않는 것이 좋다. 함수가 어떤 값을 리턴해야 한다면 출력 매개변수가 아닌 리턴문을 사용한다. C++ 11부터 추가된 이동 의미론을 적용하면 리턴해도 효율적으로 처리할 수 있다. 그리고 C++ 17에 도입된 구조적 바인딩을 사용하면 함수에서 여러 값을 리턴하는 과정을 정말 간단히 구현할 수 있다.

(예시 코드 생략)

(개인적으로 C++ 스타일의 코드를 처음 봤을 때 리턴값 없이 레퍼런스로 값을 변경하는 함수들이 많아서 놀랐는데, 역시나 저자도 리턴을 받는게 낫다고 한다)

키워드 혼동

C++에서 가장 헷갈리기 쉬운 키워드는 const와 static이다. 두 키워드는 다양한 의미로 활용되는데, 각각에 대한 미묘한 차이를 이해할 필요가 있다.

const 키워드

const는 상수를 의미하는 constant의 줄임말로 변경되면 안 될 대상을 선언할 때 사용한다. 컴파일러는 const로 지정한 대상을 변경하는 코드를 발견하면 에러를 발생시킨다. 또한 const로 지정된 대상을 최적화 할 때 효율을 더욱 높일 수 있다. 

이 키워드는 두 가지 대상에 적용할 수 있는데, 하나는 변수 또는 매개변수고 다른 하나는 메서드다.

const 변수와 매개변수

변수에 const를 붙이면 그 값이 변하지 않게 보호할 수 있다. 이 키워드는 #define으로 정의할 상수를 표현할 때 가장 많이 사용한다.

(이하 생략)

const 포인터

변수가 여러 단계의 간접 참조 연산을 거쳐야 하는 포인터로 선언 됐다면 const를 적용하기 까다롭다. 다음 코드를 보자.

int* ip;
ip = new int[10];
ip[4] = 5;

여기서 ip를 const로 지정하고 싶다고 하자. 이때 const로 지정할 대상이 ip 변수인지 아니면 이 변수가 가리키는 값인지 구별해야 한다.

포인터로 가리키는 값이 수정되지 않게 (세 번째 문장처럼 할 수 없게) 하려면 다음과 같이 const 키워드를 ip 변수의 포인터 타입 앞에 붙인다.

const int* ip;
ip = new int[10];
ip[4] = 5; // 컴파일 에러 발생

반면 변경하지 않게 하려는 대상이 ip가 가리키는 값이 아니라 ip 자체라면 다음과 같이 const를 ip 변수 앞에 붙인다.

int* const ip = nullptr;
ip = new int[10]; // 컴파일 에러 발생
ip[4] = 5;

이렇게 하면 ip 자체를 변경할 수 없기 때문에 이 변수를 선언과 동시에 초기화해야 한다.

물론 다음과 같이 ip와 ip 포인터가 가리키는 값을 모두 const로 지정할 수 있다.

int const* const ip = nullptr;

// 이렇게 작성해도 의미는 같다.
const int* const ip = nullptr;

문법이 좀 헷갈리지만 규칙은 간단하다. const 키워드는 항상 바로 왼쪽에 나온 대상에 적용된다. 위 문장에서 왼쪽에서 오른쪽으로 향할 때 첫 번째 const는 int의 바로 오른쪽에 있기 때문에 ip가 가리키는 int 값에 적용된다. 따라서 ip가 가리키는 값을 변경할 수 없게 된다. 두 번째 const는 * 연산자 오른쪽에 있기 때문에 앞에 나온 int를 가리키는 포인터, 즉 int 변수에 적용된다. 따라서 두 번째 const는 ip(포인터) 자체를 변경할 수 없게 만든다.

이 규칙이 헷갈리는 이유는 예외 문법이 있기 때문이다. 

const int* const ip = nullptr;

그런데 이런 예외 문법을 더 많이 사용한다. 방금 설명한 규칙은 포인터의 단계가 얼마든지 늘어나도 똑같이 적용된다.

const int* const* const* const ip = nullptr;
const 레퍼런스

레퍼런스에 const를 적용하는 것은 포인터에 const를 적용하는 것보다 간단하다. 그 이유는 두 가지인데, 첫째, 레퍼런스는 기본적으로 const 속성을 갖고 있다. 다시 말해 가리키는 대상을 변경하지 않는다. 그래서 명시적으로 const로 지정할 필요가 없다. 둘째, 레퍼런스에 대한 레퍼런스를 만들 수 없기 때문에 참조가 한 단계 뿐이다. 여러 단계로 참조하는 유일한 경우는 포인터에 대한 레퍼런스를 만들 때 뿐이다.

따라서 C++에서 ‘const 레퍼런스’라고 부르는 것은 대부분 다음과 같은 경우를 의미한다.

int z;
const int& zRef = z;
zRef = 4; // 컴파일 에러 발생

int&에 const를 지정하면 zRef에 다른 값을 대입할 수 없다. 포인터에 적용할 때와 마찬가지로 const int& zRef는 int const& zRef와 같다. 하지만 여기서 주의할 점은 zRef를 const라고 지정한 것과 z는 별개다. 그래서 zRef를 거치지 않고 z에 곧바로 접근하면 값을 변경할 수 있다.

const 레퍼런스는 주로 매개변수에 적용한다. 

(이하 생략)

const 메서드

클래스 메서드를 const로 지정할 수 있다. 그러면 그 클래스에서 mutable로 선언하지 않은 데이터 멤버는 변경할 수 없다.

constexpr 키워드

C++에서 제공하는 상수 표현식이라는 개념이 필요할 때가 있다. 예컨대 배열을 정의할 때 크기를 상수 표현식으로 지정해야 한다. 그러므로 다음과 같이 작성하면 에러가 발생한다.

const int getArraySize() { return 32; }

int main()
{
int myArray[getArraySize()]; // C++에서 허용하지 않는 표현
return 0;
}

constexpr 키워드를 사용하면 앞에 나온 getArraySize() 함수를 상수 표현식으로 다시 정의할 수 있다. 상수 표현식은 컴파일 시간에 계산된다.

constexpr int getArraySize() { return 32; }

int main()
{
int myArray[getArraySize()]; // OK
return 0;
}

함수에 constexpr을 적용하면 그 함수에 상당히 많은 제약사항이 적용된다. 컴파일러가 그 함수를 컴파일 시간에 평가해야 할 뿐만 아니라 함수에 부작용(side effect)이 발생하면 안 되기 때문이다.

(이하 생략)

static 키워드

static 데이터 멤버와 메서드

클래스의 데이터 멤버와 메서드를 static으로 선언할 수 있다. static으로 선언한 데이터 멤버는 static으로 지정하지 않은 멤버와 달리 객체에 속하지 않는다. 다시 말해 static 데이터 멤버는 객체 외부에 단 하나만 존재한다.

static 메서드도 객체가 아닌 클래스에 속한다는 점은 같다. static 메서드는 특정 객체를 통해 실행되지 않는다.

static 링크

static 링크에 대해 알아보기 전에 C++에서 링크를 처리하는 과정을 이해할 필요가 있다. C++는 코드를 소스 파일 단위로 컴파일해서 그 결과로 나온 오브젝트 파일들을 링크 단계에서 서로 연결한다. C++ 소스 파일(함수나 전역 변수 포함)마다 정의된 이름은 외부 링크나 내부 링크를 통해 서로 연결된다.

외부 링크(external linkage)로 연결되면 다른 소스 파일에서 이름을 사용할 수 있고, 내부 링크(internal linkage, 또는 정적 링크(static linkage))로 연결되면 같은 파일에서만 사용할 수 있다.

함수나 전역 변수는 기본적으로 외부 링크가 적용된다. 하지만 선언문 앞에 static 키워드를 붙이면 내부 링크(정적 링크)가 적용된다. 예컨대 FirstFile.cpp와 AnotherFile.cpp란 소스 파일이 있다고 하자. FirstFile.cpp는 다음과 같다.

void f();

int main()
{
f();
return 0;
}

이 파일은 f()의 프로토타입만 있고 (f()를 선언만 하고) 이를 정의하는 코드는 없다. AnotherFile.cpp는 다음과 같다.

void f();

void f()
{
std::cout << "f\n";
}

이 파일은 f()를 선언하는 코드와 정의하는 코드가 모두 있다. 이렇게 같은 함수에 대한 프로토타입을 여러 파일에 작성해도 된다. 원래 전처리기가 하는 일이 바로 이 작업이다. 전처리기는 소스 파일에 있는 #include 파일을 보고 헤더 파일에 나온 프로토타입을 소스 파일에 추가한다. 헤더 파일을 사용하는 이유는 프로토타입을 여러 파일에서 일관성 있게 유지하기 위해서다. 하지만 이 예제에서는 헤더 파일을 사용하지 않는다.

앞서 나온 소스 파일은 모두 아무런 에러 없이 컴파일되고 링크된다. f()가 외부 링크로 처리되어 main()에서 다른 파일에 있는 함수를 호출할 수 있기 때문이다.

이번에는 AnotherFile.cpp에서 f()의 선언문 앞에 static을 붙여 보자. 참고로 f()를 정의하는 코드에서는 static 키워드를 생략해도 된다. 단, 함수 선언문이 함수를 정의하는 코드보다 앞에 나와야 한다.

static void f();

void f()
{
  std::cout << "f\n";
}

이렇게 수정해도 컴파일 과정에는 아무런 에러가 발생하지 않지만 링크 단계에서 에러가 발생한다. f()를 static으로 지정해서 내부 링크로 변경돼 FirstFile.cpp에서 찾을 수 없기 때문이다. 어떤 컴파일러는 이렇게 메서드를 static으로 정의했지만 실제로 소스 파일에서 사용하지 않으면 메시지를 출력한다. (정의 코드만 있다는 것은 다른 파일에서 사용할 수도 있다는, 즉 static이 아니라는 것을 의미하기 때문이다)

static 대신 익명 네임스페이스(anonymous namespace)를 이용하여 내부 링크가 적용되게 할 수도 있다. 예컨대 다음과 같이 변수나 함수를 static으로 지정하지 말고 이름 없는 네임스페이스로 감싸면 된다.

namespace
{
void f();

void f()
{
std::cout << "f\n";
}
}

익명 네임스페이스에 속한 항목은 이를 선언한 소스 파일 안에서는 얼마든지 접근할 수 있지만 다른 소스파일에서는 접근할 수 없다. 그래서 static 키워드로 선언할 때와 효과가 같다.

extern 키워드

static과 관련된 키워드로 extern이 있다. 이름만 보면 static과 반대로 외부 링크를 지정할 때 사용하는 것 같다. 실제로 이렇게 사용하기도 한다. 예컨대 const와 typedef는 기본적으로 내부 링크로 처리된다. 여기에 extern을 붙이면 외부 링크가 적용된다.

하지만 extern의 적용 과정은 좀 복잡하다. 어떤 이름을 extern으로 지정하면 컴파일러는 이를 정의가 아닌 선언문으로 취급한다. 변수를 extern으로 지정하면 컴파일러는 그 변수에 대해 메모리를 할당하지 않는다. 따라서 그 변수를 정의하는 문장을 따로 작성해야 한다. 예컨대 AnotherFile.cpp에 나온 다음 문장이 그렇다.

extern int x;
int x = 3;

아니면 다음과 같이 extern으로 선언하는 동시에 초기화해도 된다. 그러면 선언과 정의를 한 문장에서 처리할 수 있다.

extern int x = 3;

이 경우는 굳이 extern이란 키워드를 적을 필요가 없다. extern을 붙이지 않아도 x는 기본적으로 외부 링크로 처리되기 때문이다. extern이 반드시 필요한 경우는 다음과 같이 FirstFile.cpp과 다른 소스 파일에서 x에 접근하게 만들 때다.

extern int x;

int main()
{
std::cout << x << std::endl;
}

FirstFile.cpp에서 x를 extern으로 선언했기 때문에 다른 파일에 있던 x를 여기서 사용할 수 있는 것이다. main()에서 x를 사용하려면 컴파일러가 x의 선언문을 알아야 한다. x를 선언하는 문장에 extern을 붙이지 않으면 이 문장이 x를 선언하는 것이 아니라 정의한다고 판단해서 메모리를 할당해 버린다. 그러면 링크 단계에서 에러가 발생한다. (전역 스코프에 x라는 변수가 두 개나 만들어지기 때문이다) 이럴 때는 extern을 붙여서 전역 변수로 만들면 여러 소스 파일에서 공유할 수 있다.

Caution) 전역 변수는 사용하지 않는 것이 좋다.

함수 안의 static 변수

마지막으로 소개할 static 키워드의 용도는 특정한 스코프 안에서만 값을 유지하는 로컬 변수를 만드는 것이다. 함수 안에서 static으로 지정한 변수는 그 함수만 접근할 수 있는 전역 변수와 같다. 주로 어떤 함수에서 초기화 작업 수행 여부를 기억하는 용도로 많이 사용한다.

void performTask()
{
static bool initialized = false;

if (!initialized)
{
cout << "initializing" << endl;
// 초기화 수행
initialized = true;
}

// 원하는 작업을 수행한다.
}

비로컬 변수의 초기화 순서

static 데이터 멤버와 전역 변수의 마지막 주제인 초기화 순서를 살펴보자. 전역 변수와 static 클래스 데이터 멤버는 모두 main()이 시작하기 전에 초기화된다. 이러한 변수는 소스 파일에 선언된 순서대로 초기화된다. 예컨대 다음과 같이 작성했다면 Demo::x는 y보다 먼저 초기화 된다.

class Demo
{
public:
static int x;
};

int Demo::x = 3;
int y = 4;

그런데 C++ 표준은 비로컬 변수가 여러 소스 파일에 선언 됐을 때 초기화 하는 순서는 따로 정해두지 않았다. 어떤 소스 파일에 x란 전역 변수가 있고, 다른 파일에는 y란 전역 변수가 있을 때 어느 것이 먼저 초기화되는지 알 수 없다. 어느 것이 먼저 초기화되든 상관 없는 경우가 대부분이긴 하지만 간혹 전역 변수나 static 변수가 서로 의존 관계에 있을 때는 문제가 발생할 수 있다.

(이하 설명 생략)

비로컬 변수의 소멸 순서

비로컬 변수는 생성된 순서와 반대로 소멸된다. 그런데 여러 소스 파일에 있는 비로컬 변수의 초기화 순서는 생성 순서와 마찬가지로 표준이 정해져 있지 않아서 정확한 소멸 순서를 알 수 없다.

타입과 캐스팅

타입 앨리어스

타입 앨리어스란 기존에 선언된 타입에 다른 이름을 붙이는 것이다. 타입을 새로 정의하지 않고 기존 타입 선언에 대한 동의어를 선언하는 문법이라 할 수 있다. 예컨대 다음 문장은 int*라는 타입 선언을 IntPtr란 새 이름으로 부르게 만든다.

using IntPtr = int*;

이렇게 하면 코드에서 방금 정의한 앨리어스나 기존 타입 선언 중에서 아무거나 써도 된다. 예컨대 다음과 같다.

int* p1;
IntPtr p2;

새로 정의한 타입으로 생성한 변수는 기존 타입 선언으로 생성한 변수와 완벽히 호환된다. 단순히 호환되는게 아니라 두 타입이 완전히 똑같기 때문이다.

타입 앨리어스는 너무 복잡하게 선언된 타입을 좀 더 간편하게 만들기 위한 용도로 많이 사용된다. 흔히 템플릿을 이용할 때 이런 경우가 많다. 예컨대 std::vector를 string 타입 원소에 대해 선언하려면 std::vector<std::string>과 같이 작성해야 하는데 타입 앨리어스를 사용해서 좀 더 짧고 의미가 드러나게 표현할 수 있다.

using StringVector = std::vector<std::string>

void processVector(const StringVector& vec)
{
// 코드 생략
}

타입 앨리어스를 정의할 때 스코프 지정자(scope qualifier)도 포함할 수 있다.

함수 포인터에 대한 타입 앨리어스

함수도 내부적으로 특정한 메모리 주소가 지정된다. 물론 함수의 메모리 위치를 신경 쓸 일은 많지 않다. 그런데 C++에서는 함수를 데이터로 취급할 수 있다. 다시 말해 함수의 주소를 변수 다루듯 사용할 수 있다.

함수 포인터의 타입은 매개변수 타입과 리턴 타입에 따라 결정된다. 함수 포인터를 다루는 방법 중 하나는 타입 앨리어스를 사용하는 것이다. 타입 앨리어스를 사용하면 특정한 속성을 가진 함수들을 타입 이름 하나로 부를 수 있다. 예컨대 int 매개변수 두 개와 bool 리턴값 하나를 가진 함수의 타입을 MatchFunction이란 이름으로 정의하려면 다음과 같이 작성한다.

using MatchFunction = bool(*)(int, int);

이렇게 타입 이름을 별도로 정의하면 MatchFunction을 매개변수로 받는 함수를 작성할 수 있다. 예컨대 다음 함수는 int 배열 두 개와 각각의 크기, MatchFunction을 인수로 받는다. 그리고 전달받은 두 배열에 대해 루프를 돌면서 각 원소를 MatchFunction의 인수로 전달하고 호출해서 true란 결과가 나오면 특정한 메시지를 화면에 출력한다. 여기서 주목할 부분은 MatchFuncton을 변수처럼 전달하더라도 여전히 일반 함수처럼 호출할 수 있다는 것이다.

void fintMatches(int values1[], int values2[], size_t numValues, MatchFunction matcher)
{
for (size_t i = 0; i < numValues; i++)
{
if (matcher(values1[i], values2[i])
{
cout << "Match found at position " << i << " (" << values1[i] << ", " << values2[i] << ")" << endl;
}
}
}

(이하 설명 생략. 함수 포인터는 C#에서 delegate랑 비슷하다. callback 메서드를 구성할 때 주로 사용)

메서드와 데이터 멤버를 가리키는 포인터에 대한 타입 앨리어스

C++는 클래스의 데이터 멤버와 메서드에 대한 주소를 가져오는 기능도 정식으로 지원한다. 하지만 non-static 데이터 멤버나 메서드는 반드시 객체를 통해 접근해야 한다. 클래스에서 데이터 멤버와 메서드를 저으이하는 목적은 객체마다 이들을 갖게 만들기 위해서다. 따라서 메서드나 데이터 멤버를 포인터로 접근하려면 반드시 해당 객체의 문맥에서 포인터를 역참조해야 한다.

Employee employee;
int (Employee::*methodPtr)() const = &Employee::getSalary;
cout << (employee.*methodPtr)() << endl;

두 번째 줄은 methodPtr란 변수를 선언하는데, 이 변수의 타입은 Employee에 있는 non-static const 메서드를 가리키는 포인터다. 이 메서드는 인수를 받지 않고 int 값을 리턴한다. 여기서는 선언과 동시에 변수의 값을 Employee 클래스의 getSalary() 메서드에 대한 포인터로 초기화했다. 이는 *methodPtr 앞에 Employee::가 붙은 점만 빼면 함수 포인터를 정의하는 문법과 비슷하다. 참고로 여기서는 &를 반드시 붙여야 한다.

세 번째 줄은 employee 객체를 통해 methodPtr 포인터로 getSalary() 메서드를 호출한다. 여기서 소괄호로 감싼 부분에 주목한다. 메서드 이름 뒤에 나온 ()는 *보다 우선순위가 높기 때문에 employee.*methodPtr를 소괄호로 묶어야 이 부분을 먼저 처리한다.

이 코드에서 타입 앨리어스를 활용하면 두 번째 줄을 다음과 같이 좀 더 읽기 쉽게 작성할 수 있다.

Employee employee;
using PtrToGet = int (Employee::*) () const;
PtrToGet methodPtr = &Employee::getSalary;
cout << (employee.*methodPtr)() << endl;

auto를 사용하면 훨씬 간결해진다.

Employee employee;
auto methodPtr = &Employee::getSalary;
cout << (employee.*methodPtr)() << endl;

프로그램을 작성할 때 메서드나 데이터 멤버에 대한 포인터를 사용할 일은 많지 않지만, non-static 메서드나 데이터 멤버에 대한 포인터는 객체를 거치지 않고서는 역참조 할 수 없다는 사실을 반드시 명심한다. 프로그래밍을 하다 보면 qsort()와 같이 함수 포인터를 받는 함수에 non-static 메서드의 포인터를 전달하는 실수를 저지르기 쉬운데, 이렇게 작성하면 작동하지 않는다.

Note) C++에서는 객체를 거치지 않고서도 static 데이터 멤버나 메서드의 포인터를 역참조 할 수 있다.

typedef

타입 앨리어스는 C++ 11부터 도입됐다. 그전에는 타입 앨리어스로 하는 일을 typedef로 구현해야 했는데 코드가 복잡했다. typedef도 타입 앨리어스와 마찬가지로 기존에 선언된 타입에 다른 이름을 붙여준다. 예컨대 다음과 같이 정의된 타입 앨리어스를 살펴보자.

using IntPtr = int*;

타입 앨리어스를 사용하기 전에는 다음과 같이 typedef로 비슷한 효과를 냈다.

typedef int* IntPtr;

가독성도 떨어지고 선언하는 순서도 반대라서 헷갈리기 쉽다. 코드가 좀 더 지저분해진다는 점을 제외하면 typedef는 기본적으로 타입 앨리어스와 같다. 예컨대 typedef 문을 선언한 뒤에는 다음과 같이 활용할 수 있다.

IntPtr p;

타입 앨리어스가 추가됙 ㅣ전에는 함수 포인터를 typedef로 표현해야 했다. 이 때문에 코드가 더욱 복잡해졌다. 예컨대 다음과 같이 정의된 타입 앨리어스를 살펴보자.

using FunctionType = int (*)(char, double);

이렇게 정의한 FunctionType을 typedef로 표현하면 다음과 같다.

typedef int (*FunctionType)(char, double);

타입 앨리어스와 typedef가 완전히 똑같은 것은 아니다. 템플릿에 활용할 때는 typedef보다 타입 앨리어스를 사용하는 것이 훨씬 유리하다.

Caution) typedef 보다는 타입 앨리어스를 사용하기 바란다.

캐스팅

const_cast()

const_cast()는 변수에 const 속성을 추가하거나 제거할 때 사용한다. 

(설명 생략)

C++ 17부터 std::as_const() 란 헬퍼 메서드가 추가되었다. 이 레퍼런스는 레퍼런스 매개변수를 const 레퍼런스 버전으로 변환해준다.

(설명 생략)

non-const를 const로 캐스팅할 때는 const_cast()보다 as_const()를 사용하는 것이 훨씬 간결하다. as_const()와 auto를 조합할 때 주의할 점이 있는데, auto는 레퍼런스와 const 속성을 제거한다는 것이다.

static_cast()

static_cast()는 언어에서 제공하는 명시적 변환 기능을 수행한다. 예컨대 정수에 대한 나눗셈이아닌 부동소수점에 대한 나눗셈으로 처리하도록 int를 double로 변환해야 할 때 static_cast()를 사용한다.

int i = 3;
int j = 4;
double result = static_cast<double>(i) / j;

사용자 정의 생성자나 변환 루틴에서 허용하는 명시적 변환을 수행할 때도 static_cast()를 사용할 수 있다. 상속 계층에서 하위 타입으로 다운 캐스팅할 때도 static_cast()를 사용할 수 있다.

(설명 생략)

static_cast()를 사용할 떄는 실행 시간에 타입 검사를 수행하지 않는다는 점에 주의한다. 실행 시간에 캐스팅할 때는 Base와 Derived가 실제 관련이 없어도 Base 포인터나 레퍼런스를 모두 Derived 포인터나 레퍼런스로 변환한다.

타입을 안전하게 캐스팅하도록 실행 시간에 타입 검사를 적용하려면 dynamic_cast()를 사용한다.

static_cast()를 그리 강력하지 않다. 포인터의 타입이 서로 관련 없을 때는 static_cast()를 적용할 수 없다. 또한 변환 생성자가 제공되지 않는 타입의 객체에도 적용할 수 없다. 기본적으로 C++ 타입 규칙에서 허용하지 않는 것은 모두 할 수 없다고 보면 된다.

reinterpret_cast()

reinterpret_cast()는 static_cast() 보다 강력하지만 안전성은 좀 떨어진다. C++ 타입 규칙에서 허용하지 않더라도 상황에 따라 캐스팅하는 것이 적합할 때 적용할 수 있다. 예컨대 서로 관련이 없는 레퍼런스끼리 변환할 수도 있다.

마찬가지로 상속 계층에서 아무런 관련이 없는 포인터 타입끼리도 변환할 수 있다. 이런 포인터는 흔히 void* 타입으로 캐스팅한다. 이 작업은 내부적으로 처리되기 때문에 명시적으로 캐스팅하지 않아도 되지만 이렇게 void*로 변환한 것을 다시 원래 타입으로 캐스팅할 때는 reinterpret_cast()를 사용해야 한다. void* 포인터는 메모리의 특정 지점을 가리키는 포인터일 뿐 void* 포인터 자체에는 아무런 타입 정보가 없기 때문이다.

class X {};
class Y {};

int main()
{
X x;
Y y;
X* xp = &x;
Y* yp = &y;

// 서로 관련 없는 클래스 타입의 포인터를 변환할 때는 reinterpret_cast()를 써야 한다.
xp = reinterpret_cast<X*>(yp);

// 포인터를 void*로 변환할 때는 캐스팅하지 않아도 된다.
void* p = xp;

// 변환된 void*를 다시 원래 포인터로 복원할 때는 reinterpret_cast()를 써야 한다.
xp = reinterpret_cast<X*>(p);

X& xr = x;
Y& yr = reinterpret_cast<Y&>(x);

return 0;
}

reinterpret_cast()를 활용하는 예로 ‘단순 복사 가능 타입’에 대해 바이너리 I/O를 수행하는 경우를 들 수 있다. 예컨대 이런 타입의 값을 파일에 바이트로 썼다가 나중에 다시 파일을 읽어서 메모리로 불러올 때 reinterpret_cast()를 적용하면 원래 값 그대로 정확히 해석할 수 있다.

하지만 reinterpret_cast()를 사용할 때는 타입 검사를 하지 않고 변환할 수 있기 때문에 주의해야 한다.

dynamic_cast()

dynamic_cast()는 같은 상속 계층에 속한 타입끼리 캐스팅할 때 실행 시간에 타입을 검샇나다. 포인터나 레퍼런스를 캐스팅할 때 이를 이용할 수 있다. dynamic_cast()는 내부 객체의 타입 정보를 실행 시간에 검사한다. 그래서 캐스팅하는 것이 적합하지 않다고 판단하면 포인터에 대해서는 널 포인터를 리턴하고 레퍼런스에 대해서는 std::bad_cast 익셉션을 발생 시킨다.

예컨대 다음과 같이 클래스 계층이 구성된 경우를 보자.

class Base
{
public:
virtual ~Base() = default;
};

class Derived : public Base
{
public:
virtual ~Derived() = default;
};

이때 dynamic_cast()에 대한 올바른 사용 예는 다음과 같다.

Base* b;
Derived* d = new Derived();
b = d;
d = dynamic_cast<Derived*>(b);

반면 레퍼런스에 대해 다음과 같이 dynamic_cast()를 적용하면 익셉션이 발생한다.

Base base;
Derived derived;
Base& br = base;

try
{
Derived& dr = dynamic_cast<Derived&>(br);
}
catch (const bad_cast&(
{
cout << "Bad cast!" << endl;
}

참고로 static_cast()나 reinterpret_cast()로도 같은 상속 계층의 하위 타입으로 캐스팅할 수 있다. 차이점은 dynamic_cast()는 실행 시간에 타입 검사를 수행하는 반면 static_cast()나 reinterpret_cast()는 문제가 되는 타입도 그냥 캐스팅한다는 것이다.

앞서 설명 했듯이 실행 시간의 타입 정보는 객체의 vtable에 저장된다. 따라서 dynamic_cast()를 적용하려면 클래스에 virtual 메서드가 최소한 한 개 이상 있어야 한다. 그렇지 않은 객체에 대해 dynamic_cast()를 적용하면 컴파일 에러가 발생한다.

캐스팅 정리

상황 캐스팅 방법
const 속성 제거 const_cast()
언어에서 허용하는 명시적 변환(int를 double로, int를 bool로) static_cast()
사용자 저으이 생성자나 변환 연산자에서 지원하는 명시적 변환 static_cast()
서로 관련 없는 타입의 객체끼리 변환 불가능
같은 상속 계층에 있는 클래스 타입의 객체 포인터 사이의 변환 dynamic_cast() 권장
static_cast()도 가능
같은 상속 계층에 있는 클래스 타입의 객체 레퍼런스 사이의 변환 dynamic_cast() 권장
static_cast()도 가능
서로 관련 없는 타입의 포인터 사이의 변환 reinterpret_cast()
서로 관련 없는 타입의 레퍼런스 사이의 변환 reinterpret_cast()
함수 포인터 사이의 변환 reinterpret_cast()

스코프

C++ 프로그래머라면 스코프(유효 범위)란 개념을 확실히 이해하고 있어야 한다. 변수, 함수, 클래스 이름을 비롯하여 프로그램에서 사용하는 모든 이름은 스코프에 속한다. 스코프는 네임스페이스, 함수 정의, 중괄호 블록, 클래스 정의 등으로 생성한다.

어떤 변수나 함수, 클래스를 주어진 이름으로 접근할 때 가장 가까운 스코프부터 시작하여 전역 스코프까지 검색한다. 그 이름을 전역 스코프에서도 찾지 못하면 컴파일러는 정의되지 않은 심벌이라는 에러(undefined symbol error)를 출력한다.

간혹 스코프 안에 다른 스코프와 같은 이름이 있으면 다른 스코프의 이름을 가린다. 또 어떤 경우는 현재 코드에서 사용하려는 대상의 스코프가 디폴트 스코프 범위에 속하지 않을 수도 있다. 이렇게 디폴트 스코프에 속하지 않은 이름을 사용할 때는 스코프 지정 연산자인 ::로 해당 스코프를 지정할 수 있다.

(이하 설명 생략)

어트리뷰트

어트리뷰트란 특정 벤더에서만 제공하는 정보나 옵션을 소스 코드에 추가하는 메커니즘이다. C++ 표준에 어트리뷰트가 추가되기 전에는 벤더마다 __attribute__, __declspec 과 같은 속성을 나름대로 정했다. C++ 11부터는 이런 속성을 모두 이중 대괄호를 이용하여 [[어트리뷰트]]와 같은 문법으로 표기하도록 표준화 했다.

C++ 표준은 6가지 어트리뷰트만 지원하는데, [[carries_dependency]] 어트리뷰트는 좀 특이해서 생략하고 나머지를 살펴보겠다.

[[noreturn]]

[[noreturn]]은 함수가 호출한 측으로 제어를 리턴하지 않는다는 것을 의미한다. 주로 프로세스나 스레드를 종료시키거나 익셉션을 던지는 함수에 이 어트리뷰트를 지정한다. 함수에 이 어트리뷰트를 붙이면 의도가 명확히 드러나기 때문에 컴파일러는 경고나 에러를 발생싴지 않는다. 예컨대 다음과 같다.

[[noreturn]] void forceProgramTermination()
{
std::exit(1);
}

bool isDongleAvailable()
{
bool isAvailable = false;
// 라이선싱 동글이 있는지 검사한다.
return isAvailable;
}

bool isFeatureLicensed(int featureId)
{
if (!isDongleAvailable())
{
// 라이선싱 동글을 찾지 못해서 프로그램 실행을 중단한다.
forceProgramTermination();
}
else
{
bool isLicensed = false;
// 동글을 발견하면 라이선스를 검사해서 주어진 기능을 제공하는지 확인한다.
return isLicensed;
}
}

int main()
{
bool isLicensed = isFeatureLicensed(42);
}

[[deprecated]]

[[deprecated]]는 더는 지원하지 않는 대상을 지정할 때 사용한다. 다시 말해 현재 사용할 수는 있지만 권장하지 않는 기능임을 표시한다. 이 어트리뷰트에 지원 중단 사유를 인수로 지정할 수 있다.

[[deprecated("Unsafe method, please use xyz")]] void func();

[[fallthrough]]

C++ 17부터 [[fallthrough]]란 어트리뷰트가 추가됐다. 이 어트리뷰트는 switch 문에서 의도적으로 폴스루(fallthrough, 완료되지 못한)를 적용하고 싶을 때 사용한다. 의도적으로 폴스루를 적용할 switch 문에 이 어트리뷰트를 지정하지 않으면 경고 메시지를 출력할 수 있다. 빈 케이스 문에는 지정하지 않아도 된다.

switch (backgroundColor)
{
case Color::DarkBlue:
doSomthingForDarkBlue();
[[fallthrough]];

case Color::Black:
// 배경색이 다크 블루나 블랙일 때 실행된다.
doSomthingForBlackOrDarkBlue();
break;

case Color::Red:
case Color::Green:
// 배경색이 레드나 그린일 때 실행된다.
break;
}

[[nodiscard]]

값을 리턴하도록 정의된 함수에 [[nodiscard]]를 지정하면 그 함수를 이용하는 코드에서 리턴 값을 사용하지 않을 때 경고 메시지가 발생한다.

[[nodiscard]] int func()
{
return 42;
}

int main()
{
func();
return 0;
}

이 기능은 에러 코드를 리턴하는 함수에 적용하면 좋다. 이런 함수에 [[nodiscard]]를 붙이면 에러 코드를 무시할 수 없게 만들 수 있다.

[[maybe_unused]]

[[maybe_unused]]는 프로그램에서 사용하지 않는 코드를 발견해도 경고 메시지를 출력하지 말라고 컴파일러에게 지시할 때 사용한다.

int func(int param1, [[maybe_unused]] int param2)
{
return 42;
}

벤더 정의 어트리뷰트

지금까지 살펴 본 어트리뷰트는 대부분 벤더에서 정의한 것이다. 벤더에서 이런 어트리뷰트를 정의할 때는 프로그램의 의미를 변경하는 용도가 아닌 컴파일러에서 코드를 최적화하거나 에러를 검사하는데 도움이 되기 위한 목적으로 제공해야 한다.

벤터마다 제공하는 어트리뷰트끼리 충돌할 수 있기 때문에 [[clang::noduplicate]]와 같이 벤더 이름을 붙이는 것이 좋다.

사용자 정의 리터럴

C++은 표준 리털을 다양하게 제공한다. 이런 리터럴은 프로그램에서 곧바로 사용할 수 있다.

  • ‘a’ : 문자
  • “character array”: 0으로 끝나는 문자 배열, 즉 C 스타일 스트링
  • 3.14f: 부동소수점 값
  • 0xabc: 16진수 숫자

C++는 리터럴을 직접 정의하는 기능도 제공한다. 이런 사용자 정의 리터럴은 반드시 언더스코어로 시작해야 한다. 그리고 언더스코어 다음에 나오는 첫 문자는 반드시 소문자여야 한다.

사용자 정의 리터럴은 리터럴 연산자를 정의하는 방식으로 구현한다. 리터럴 연산자는 미가공 모드(raw mode)나 가공 모드(cooked mode)로 작동한다. 미가공 모드에서는 단순히 문자가 나열된 것으로 취급하지만 가공 모드에서는 특정한 타입의 값으로 해석한다. 

예컨대 123이란 리터럴을 살펴보자. 미가공 모드의 리터럴 연산자는 이 값을 ‘1’, ‘2’, ‘3’이란 문자의 나열로 받지만 가공 모드의 리터럴 연산자는 123이란 정숫값으로 처리한다. 또 다른 예로 0x23이란 리터럴을 살펴보자. 미가공 모드에서는 ‘0’, ‘x’, ‘2’, ‘3’과 같이 문자를 나열한 것으로 처리하는 반면 가공 모드에서는 35라는 정수로 처리한다. 

가공 모드의 리터럴 연산자는 다음 두 조건 중 하나를 만족해야 한다.

  • 숫자값을 처리하려면 타입이 unsigned long long, long double, char, wchar_t, char16_t, char32_t 중 하나로 된 매개변수가 필요하다.
  • 스트링을 처리하려면 매개변수가 두 개 있어야 한다. 첫 번째는 문자 배열을 두 번째는 그 배열의 길이를 지정한다. (ex: const char* str, size_t len)

예컨대 복소수 리터럴을 정의하는 가공 모드 리터럴 연산자를 다음과 같이 _i라는 사용자 정의 리터럴로 구현할 수 있다.

std::complex<long double> operator"" _i(long double d)
{
return std::complex<long double>(0, d);
}

그러면 _i 리터럴을 다음과 같이 사용할 수 있다.

std::complex<long double> c1 = 9.634_i;
auto c2 = 1.23_i; // c2의 타입은 std::complex<long double>이 된다.

또 다른 예로 std::string 리터럴을 정의한느 사용자 정의 리터럴 _s에 대한 가공 모드의 리터럴 연산자를 다음과 같이 구현할 수 있다.

std::string operator"" _s(const char* str, size_t len)
{
return std::string(str, len);
}

그러면 다음과 같이 사용할 수 있다.

std::string str1 = "Hello World"_s;
auto str2 = "Hello World"_s; // str2의 타입은 std::string이 된다.

여기서 _s 리터럴을 사용하지 않으면 const char* 타입으로 자동 변환된다.

미가공 모드 리터럴 연산자를 정의하려면 0으로 끝나는 C 스타일 스트링을 const char* 타입으로 받는 매개변수를 한 개 정의해야 한다. 다음 코드는 _i라는 리터럴을 미가공 모드로 작동하도록 정의한다.

std::complex<long double> operator"" _i(const char* p)
{
// 구현 코드 생략
// C 스타일 스트링을 파싱해서 복소수로 변환하는 코드를 구현한다.
}

미가공 모드 리터럴 연산자를 사용하는 방법은 가공 모드 리터럴 연산자와 동일하다.

사용자 정의 리터럴에 대한 표준

C++은 다음과 같은 표준 사용자 정의 리터럴을 제공한다. 표준 사용자 정의 리터럴은 언더스코어로 시작하지 않는다는 점에 주의한다.

  • s: std::string 리터럴을 생성한다. 예컨대 다음과 같다.
    • auto myString = “Hello World”s;
  • sv: std::string_view 리터럴을 생성한다.
    • auto myStringView = “Hello World”sv;
  • h, min, s, ms, us, ns: 시간 간격을 표현하는 std::chrono::duration 리터럴을 생성한다.
    • auto myDuration = 42min;
  • i, il, if: 각각 complex<double>, complex<long double, complex<float> 타입의 복소수 리터럴을 생성한다.
    • auto myComplexNumber = 1.3i;

헤더 파일

헤더 파일이란 서브시스템이나 코드에 추상 인터페이스를 제공하는 메커니즘이다. 헤더를 사용할 때 헤더파일이 중복되거나 순환 참조가 발생하지 않도록 주의해야 한다.

인클루드 가드(include guard) 메커니즘을 사용하면 중복 정의를 피할 수 있다. 다음 코드는 Logger.h에서 인클루드 가드를 적용한 예를 보여주고 있다. 헤더 파일을 시작할 떄 #ifndef 지시자를 이용하여 특정한 키가 이미 정의 됐는지 검사한다. 그 키가 앞에서 정의된 적이 있다면 컴파일러는 #endif 문장까지 건너뛴다.

#ifdef LOGGER_H
#define LOGGER_H

class Logger
{
// ...
};

#endif // LOGGER_H

요즘 나온 컴파일러는 거의 모두 #pragma once 디렉티브를 제공하기 때문에 이렇게 인클루드 가드를 작성하지 않아도 된다.

#pragma once

class Logger
{
// ...
};

헤더 파일에 관련된 문제를 방지하기 위한 또 다른 기능으로 전방 선언(포워드 선언  forward declaration)이란 것도 있다. 어떤 클래스를 참조해야 하는데 그 클래스에 대한 헤더 파일을 인클루드 할 수 없다면 (예컨대 그 클래스가 현재 작성하는 클래스를 너무 많이 의존하고 있어서) 그 클래스에 대한 정의를 #include 메커니즘으로 불러오지 않고도 사용하게 만들 수 있다.

물론 이렇게 선언한 클래스에 대한 구현 사항은 컴파일러가 전혀 모르기 때문에 코드에서 실제로 사용할 수는 없다. 단, 이름이 있는 클래스라면 링크 단계가 모두 끝난 후에는 얼마든지 사용할 수 있다. 그래도 코드에서 전방 선언한 클래스에 대해 포인터나 레퍼런스를 만들어 쓸 수는 있다. 그리고 이렇게 전방 선언된 클래스를 값 전달 방식의 함수 매개변수나 리턴값의 타입으로 지정할 수도 있다. 물론 이런 함수를 정의하는 코드 뿐만 아니라 이를 호출하는 코드도 전방 선언된 클래스를 정의하는 헤더 파일을 정확히 인클루드 해야 한다.

예컨대 Logger 클래스에서 사용자 설정사항을 관리하도록 Preferences란 클래스를 사용한다고 하자. 그런데 Preferences 클래스는 다시 Logger 클래스를 이용해서 순환 의존 관계가 발생한다. 이런 상황은 인클루드 가드로는 해결할 수 없고, 전방 선언을 이용해야 한다. 다음 코드를 보면 헤더 파일을 인클루드하지 않고도 Preferences 클래스를 사용하도록 Logger.h 헤더 파일에 Preferences 클래스를 전방 선언한다.

#pragma once

#include <string_view>

class Preferences; // 전방 선언

class Logger
{
public:
static void setPreferences(const Preferences& prefs);
static void logError(std::string_view error);
};

헤더 파일에 다른 헤더 파일을 인클루드하는 대신 전방 선언을 적용하는 것이 바람직하다. 그러면 작성한 헤더 파일이 다른 헤더 파일을 의존하는 것을 제거해서 컴파일 시간이 크게 줄어들기 때문이다. 물론 전방 선언한 타입을 선언한 헤더 파일을 구현 파일에서 정확히 인클루드 해야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

C++ 17부터 특정한 헤더 파일이 존재하는지 확인할 수 있도록 __has_include(“파일명”)과 __has_include(<파일명>) 이란 전처리 상수가 추가됐다. 이 상수는 해당 헤더 파일이 존재하면 1, 그렇지 않으면 0이 된다. 예컨대 C++ 17에서 <optional> 헤더 파일이 정식으로 지원되기 전에 <experimental/optional>이란 초기 버전의 헤더 파일이 제공됐다. 이때 __has_include()를 이용하면 현재 시스템에서 둘 중 어느 헤더 파일을 지원하는지 확인할 수 있다.

#if __has_include(<optional>)
#include <optional>
#elif __has_include(<experimental/optional>)
#include <experimental/optional>
#endif

C 유틸리티

가변 길이 인수 리스트

기존 C 언어의 기능인 가변 길이 인수 리스트(variable-length argument list)를 살펴보자 간혹 레거시 코드에서 사용하기 때문에 알아둘 필요가 있다. 물론 새로 작성하는 코드에서는 타입에 안전한 가변 길이 인수 리스트를 제공하는 가변 인수 템플릿(variadic template)을 사용하는 것이 바람직하다.

C/C++는 가변 길이 인수를 가진 함수를 직접 정의하는데 필요한 문법과 유틸리티 매크로를 제공한다. 이렇게 정의한 함수는 printf()와 형태가 비슷하다. 물론 이 기능이 필요할 때가 많지 않지만 간혹 쓰면 편할 때가 있다. 간단한 구현 예는 다음과 같다.

#include <cstudio>
#include <cstdarg>

bool debug = false;

void debugOut(const char* str, ...)
{
va_list ap;

if (debug)
{
va_start(ap, str);
vfprintf(stderr, str, ap);
va_end(ap);
}
}

코드에서 가장 먼저 주목할 부분은 debugOut()에 대한 프로토타입이다. 이 부분을 보면 이름과 타입이 지정된 str이라는 매개변수 뒤에 생략 부호(…)가 있다. 이는 임의 개수와 타입의 인수를 받을 수 있다는 것을 의미한다. 이렇게 선언된 인수는 반드시 <cstdarg>에 정의된 매크로로 접근해야 한다.

함수 본문을 보면 va_list 타입의 변수를 선언한 뒤 va_start를 호출해서 초기화했다. va_start()의 두 번째 매개변수는 반드시 매개변수 리스트의 이름 있는 변수 중에서 오른쪽 끝에 있는 것이어야 한다.

가변 길이 인수 리스트를 가진 함수라면 반드시 이름 있는 매개변수가 한 개 이상 있어야 한다. 여기서 정의한 debugOut() 함수는 인수 리스트를 곧바로 <cstudio>에 있는 표준 함수인 vfprintf()로 전달한다. vfprintf()를 호출한 결과가 리턴되면 va_end()를 호출해서 가변 길이 인수 리스트에 대한 접근을 종료한다. va_start()를 호출했다면 반드시 이에 대응되는 va_end()를 호출해서 함수의 스택 상태를 일관성 있게 유지해야 한다.

이렇게 정의한 debugOut() 함수를 사용하는 방법은 다음과 같다.

debug = true;
debugOut("int %d\n", 5);
debugOut("String %s and int %d\n", "hello", 5);
debugOut("Many ints: %d, %d, %d, %d, %d\n", 1, 2, 3, 4, 5);

인수에 접근하기

실제 인수에 직접 접근하고 싶다면 va_arg()를 사용한다. 이 매크로는 va_list와 이를 해석할 타입을 인수로 받는다. 하지만 아쉽게도 인수 리스트의 끝은 명시적으로 지정하지 않고서는 알아낼 방법이 없다. 예컨대 첫 번째 매개변수를 매개변수 개수로 지정할 수 있다. 또는 포인터가 여러 개 있을 때 마지막 포인터를 nullptr로 지정하는 방법도 있다. 이 외에도 다양한 방법이 있지만 모두 프로그래머 입장에서 번거롭다.

다음 코드는 함수를 호출한 측에서 이름 있는 매개변수 중 첫 번째 매개변수로 인수 개수를 지정할 수 있게 구현하는 예를 보여주고 있다. 이 함수는 임의 개수의 int 값을 받아서 출력한다.

void printInts(size_t num, ...)
{
int temp;
va_list ap;

va_start(ap, num);

for (size_t i = 0; i < num; ++i)
{
temp = va_arg(ap, int);
cout << temp << " ";
}

va_end(ap);

cout << endl;
}

이렇게 정의한 printInts()를 호출하는 방법은 다음과 같다. 이 문자을 보면 첫 번째 매개변수는 뒤에 나올 정수 인수 개수를 지정한다.

printInts(5, 5, 4, 3, 2, 1);

C 스타일 가변 길이 인수 리스트를 사용하면 안 되는 이유

C 스타일 가변 길이 인수 리스트는 그리 안전하지 않다. printInts() 함수 예제에서 알 수 있듯이 다음과 같은 문제가 있다.

  • 매개변수 개수를 알 수 없다. printInts()의 예를 보면 호출한 측이 첫 번째 인수에 정확한 개수를 입력하길 믿는 수 밖에 없다. debugOut()의 경우 문자 배열 뒤에 나올 인수 개수가 스트링에 담긴 포매팅 코드 개수와 일치한다고 믿는 수 밖에 없다.
  • 인수 타입을 알 수 없다. va_arg() 매크로는 전달된 타입을 이용하여 현재 시점의 값을 해석한다. 그런데 va_arg()를 통해 얼마든지 그 값이 다른 타입으로 해석될 수도 있다. 따라서 타입이 정확한지 검증할 방법이 없다.

Caution) C 스타일 가변 길이 인수 리스트는 가능하면 사용하지 않는 것이 좋다. 그 대신 값을 std::array나 vector에 담아서 전달하고, 1장에서 설명한 것처럼 그 값을 이니셜라이저 리스트로 초기화하거나, 22장에서 설명하는 타입에 안전한 가변 길이 인수 리스트에 대한 variadic 템플릿을 사용한다.

전처리 매크로

C++ 전처리기로 간단한 함수 형태의 매크로를 정의할 수 있다. 예컨대 다음과 같다.

#define SQUARE(x) ((x) * (x)) // 매크로 뒤에는 세미콜론을 붙이지 않는다.

int main()
{
cout << SQUARE(5) << endl;
return 0;
}

매크로는 C 언어에서 물려 받은 기능으로 타입 검사를 하지 않고 이를 호출한 모든 부분을 전처리기가 텍스트 바꾸기를 하듯 교체한다는 점을 제외하면 inline 함수와 비슷하다. 이때 전처리기는 실제 함수 호출 메커니즘을 적용하지 않는다. 그래서 의도하지 않은 결과가 발생할 수 있다.

예컨대 SQUARE에 2+3이란 인수를 지정하는 경우를 생각해 보자.

cout << SQUARE(2+3) << endl;

위 결과는 25가 아닌 11이 되는데, 매크로는 함수 호출 메커니즘이 아닌 단순 텍스트 바꾸기이기 때문이다. 떄문에 앞서 매크로를 사용한 문장은 실제로 다음과 같다.

cout << (2+3 * 2+3) << endl;

매크로를 잘못 사용하면 성능에 지장을 줄 수 있다. 예컨대 SQUARE를 다음과 같이 호출한 경우를 보자.

cout << SQUARE(veryExpensiveFunctionCallToComputeNumber()) << endl;

전처리기는 이 문장을 다음과 같이 바꾼다.

cout << (veryExpensiveFunctionCallToComputeNumber() & veryExpensiveFunctionCallToComputeNumber()) << endl;

그러면 굉장히 오래 걸리는 함수를 두 번이나 호출하게 된다. 매크로를 사용하면 안 되는 또 다른 이유다.

매크로는 디버깅할 때도 문제가 된다. 전처리기는 찾아서 바꾸는 방식으로 처리하기 때문에 프로그래머가 볼 때와 컴파일러나 디버거가 볼 때 코드 형태가 서로 다르다. 그래서 매크로 대신 인라인 함수를 사용하는 것이 바람직하다.

이러한 세부사항을 소개하는 이유는 상당수의 C++ 코드에서 여전히 매크로를 사용하기 때문이다. 따라서 기존에 작성된 코드를 읽고 관리하려면 개념을 정확히 이해할 필요가 있다.

Note) 컴파일러는 대부분 전처리기를 거친 소스 코드를 다른 파일이나 표준 출력으로 출력하는 기능을 제공한다. 이 겨로가를 이용해 전처리기의 코드 처리 방식을 파악할 수 있다.

[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.