본문 바로가기
Object Oriented Programming(C++)/전문가를 위한 C++

전문가를 위한 C++ | Chapter 11 C++의 까다롭고 유별난 부분(상)

by continue96 2022. 3. 5.

11.1 레퍼런스

 C++에서 레퍼런스(reference)란 일종의 변수에 대한 앨리어스(alias), 즉 별칭이다. 변수에 대한 다른 이름이라 생각해도 된다.

 

11.1.1 레퍼런스 변수

 레퍼런스 변수는 반드시 생성하자마자 초기화해야 한다. 예를 들면 다음과 같다. xRef는 x에 대한 또 다른 이름으로 xRef를 사용하는 것은 x를 사용하는 것과 같다.

int x = 5;
int& xRef = x;
int& yRef; /* 컴파일 에러 */

 

 정수 리터럴처럼 이름 없는 값에 대해서는 레퍼런스를 생성할 수 없다. 단, const 값에 대해서는 레퍼런스를 생성할 수 있다. 임시 객체도 마찬가지로 non-const 레퍼런스는 만들 수 없지만, const 레퍼런스는 얼마든지 만들 수 있다.

int& ref1 = 5; /* 컴파일 에러 */
const int& ref2 = 10;

std::string getString() {
	return "Hello World!";
}
std::string& stringRef1 = getString(); /* 컴파일 에러 */
const std::string& stringRef2 = getString();
CAUTION 레퍼런스 변수는 반드시 생성 즉시 초기화해야 한다. 레퍼런스 데이터 멤버는 그 클래스의 생성자 이니셜라이저로 초기화해야 한다.

 

 11.1.1.1 레퍼런스 대상 변경하기

 레퍼런스는 처음 초기화할 때 지정한 변수만 가리킨다. 레퍼런스는 한 번 생성되고 나면 가리키는 대상을 바꿀 수 없다. 레퍼런스를 선언할 때 어떤 변수를 대입하면 레퍼런스는 그 변수를 가리킨다. 하지만 이렇게 한 번 선언된 레퍼런스에 다른 변수를 대입하면 레퍼런스가 가리키는 대상이 바뀌는 것이 아니라 레퍼런스가 원래 가리키던 변수의 값이 새로 대입한 변수의 값으로 바뀌게 된다. 예를 들면 다음과 같다.

int x = 5, y = 10, z = 15;
int& xRef = x;
int& zRef = z;

xRef = y; /* x의 값을 10으로 변경합니다. */
zRef = xRef; /* z의 값을 10으로 변경합니다. */
CAUTION 레퍼런스를 초기화하고 나면 레퍼런스가 가리키는 변수는 변경할 수 없고, 그 변수의 값만 변경할 수 있다.

 

 11.1.1.2 포인터에 대한 레퍼런스·레퍼런스에 대한 포인터

 포인터 타입을 가리키는 레퍼런스를 만들 수 있다. 예를 들어, int 포인터를 가리키는 레퍼런스를 다음과 같이 만들 수 있다.

int* intPtr = nullptr;
int*& intPtrRef = intPtr;
intPtrRef = new int;
*intPtrRef = 5;

 또한, 레퍼런스가 가져온 주소는 그 레퍼런스가 가리키는 변수의 주소와 같다. 예를 들면 다음과 같다.

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

 한 가지 주의할 점은 레퍼런스에 대한 레퍼런스를 선언할 수 없다는 것이다. 예를 들어, int&&나 int&*와 같이 선언할 수 없다.

 

11.1.2 레퍼런스 데이터 멤버

 클래스의 데이터 멤버를 레퍼런스 타입으로 정의할 수 있다. 레퍼런스 데이터 멤버는 반드시 생성자 본문이 아닌 생성자 이니셜라이저에서 초기화해야 한다. 예를 들면 다음과 같다.

class Foo {
public:
	Foo(int& ref) : mRef(ref) { } /* 생성자 이니셜라이저에서 레퍼런스 데이터 멤버를 초기화합니다. */
private:
	int& mRef;
};

 

11.1.3 레퍼런스 매개 변수

 레퍼런스는 주로 함수나 메서드의 매개 변수로 많이 사용한다. 매개 변수를 레퍼런스 타입으로 선언하면 인수를 레퍼런스 전달 방식으로 처리한다. 여기서 전달된 값을 수정하면 인수로 지정한 원본 변수의 값도 함께 바뀐다. 예를 들어, 다음과 같이 두 개의 int 값을 서로 맞바꾸는 함수를 살펴보자.

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

int x = 10, y = 20;
swap(x, y);

 swap을 호출할 때 x와 y를 인수로 지정하면 first 매개 변수는 x를, second 매개 변수는 y를 가리키도록 초기화된다. 그래서 swap에서 fisrt와 second 값을 변경하면 x와 y의 값도 변경된다.

 

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

 매개 변수가 레퍼런스 타입인 함수나 메서드에 포인터를 전달하려면 포인터를 역참조해 레퍼런스로 변환하여 전달한다. 예를 들어 swap을 호출할 때 포인터를 전달하려면 다음과 같이 작성한다.

int x = 5, y = 10;
int* xPtr = &x, yPtr = &y;
swap(*xPtr, *yPtr);

 

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

 레퍼런스 전달 방식은 인수에 대한 복사본을 만들지 않기 때문에 다음과 같은 두 가지 장점이 있다.

  • 효율성: 크기가 큰 객체나 구조체는 복사 오버헤드가 크다. 레퍼런스 전달 방식을 사용하면 객체나 구조체에 대한 레퍼런스만 함수에 전달한다.
  • 정확성: 값 전달 방식을 지원하지 않거나, 지원하더라도 깊은 복사가 적용되지 않을 수 있다. 동적 메모리를 사용하는 객체는 반드시 복사 생성자와 복사 대입 연산자를 직접 정의해서 깊은 복사를 제공해야 한다.

 값 전달 방식은 기본 타입(int, double 등)이나 함수 안에서 수정할 필요가 없을 때 사용하고, 나머지 경우는 레퍼런스 전달 방식 혹은 const 레퍼런스 전달 방식을 사용하는 것이 좋다.

 

11.1.4 레퍼런스 리턴 값

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

CAUTION 함수나 메서드에서 유효 범위가 그 함수나 메서드로 제한되는 지역 변수(지역 스택 변수, 힙 변수, 그리고 지역 정적 변수)는 절대로 레퍼런스로 반환하면 안 된다.

 

11.1.5 우측 값 레퍼런스

 좌측 값(lvalue)은 변수처럼 이름과 주소를 가지면서 대입문의 왼쪽에 나온다. 우측 값(rvalue)은 상수 값과 임시 객체처럼 lvalue가 아닌 나머지를 말한다. 일반적으로 rvalue는 대입 연산자의 오른쪽에 나온다. rvalue 레퍼런스는 9장에서 자세히 설명했다.

// 매개 변수를 lvalue 레퍼런스로 정의합니다.
void handleMessage(std::string& msg) {
	cout << "좌측값으로 함수를 호출합니다." << msg << endl;
}

// 매개 변수를 rvalue 레퍼런스로 정의합니다.
void handleMessage(std::string&& msg) {
	cout << "우측값으로 함수를 호출합니다." << msg << endl;
}

// 리터럴은 lvalue가 아니므로 rvalue 버전을 호출합니다.
handleMessage("Hello World");

// 임시 값은 lvalue가 아니므로 rvalue 버전을 호출합니다.
std::string a = "Hello "
std::string b = "World"
handleMessage(a + b);

 

11.2 키워드 혼동

 C++에서 가장 헷갈리기 쉬운 키워드는 const와 static이다. 두 키워드는 다양한 의미로 활용된다.

 

11.2.1 const 키워드

 const 키워드는 상수(constant)의 줄임말로서 변경되면 안 될 대상을 선언할 때 사용한다. 이 키워드는 변수·매개 변수 그리고 메서드에 적용할 수 있다.

 

 11.1.2.1 const 변수·const 매개 변수

 변수에 const를 붙이면 그 값이 변하지 않게 보호할 수 있다. 예를 들어, #define으로 정의할 PI라는 상수를 다음과 같이 선언할 수 있다. 전역 변수나 클래스의 데이터 멤버뿐만 아니라 모든 종류의 변수에 const를 붙일 수 있다.

#define PI 3.141592
const double PI = 3.141592;

 

① const 포인터

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

int* intPtr;
intPtr = new int[10];
intPtr[0] = 10;

 여기서 먼저 const로 지정할 대상이 intPtr 변수인지 아니면 이 변수가 가리키는 값인지 구분해야 한다. 포인터로 가리키는 값이 수정되지 않게 보호하려면 const 키워드를 intPtr 변수의 포인터 타입(*) 앞에 붙인다.

const int* intPtr;
또는
int const* intPtr;

intPtr = new int[10];
intPtr[0] = 10; /* 컴파일 에러 */

 반면, 변경하지 않게 하려는 대상이 intPtr 그 자체라면 다음과 같이 const를 intPtr 변수 바로 앞에 붙인다. 이렇게 하면 intPtr 자체를 변경할 수 없기 때문에 변수를 선언함과 동시에 초기화해야 한다.

int* const intPtr = nullptr;
intPtr = new int[10]; /* 컴파일 에러 */

int* const intPtr = new int[10]; /* intPtr를 선언과 동시에 초기화합니다. */

 예외 문법이 있긴 하지만 규칙은 간단하다. const 키워드는 항상 바로 왼쪽에 나온 대상에 적용된다.

int const* const intPtr = nullptr;
또는
const int* const intPtr = nullptr; /* 예외 문법이지만 더 대중적으로 사용됩니다. */

 

② const 레퍼런스

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

int x = 5;
const int& xRef = x;
xRef = 10; /* 컴파일 에러 */
x = 10;

 int&에 const를 지정하면 xRef에서 다른 값을 대입할 수 없다. 여기서 주의할 점은 xRef를 const로 지정한 것과 x는 별개 이므로 x에 곧바로 접근하면 값을 변경할 수 있다.

 const 레퍼런스는 주로 매개 변수에 적용한다. 특히, const 레퍼런스로 지정하면 인수를 효율적으로 전달하면서 값을 변경할 수 없게 만들 수 있다.

CAUTION 매개 변수로 전달할 대상이 객체라면 기본적으로 const 레퍼런스로 선언한다. 전달할 객체를 변경할 일이 있을 때만 const를 생략한다.

 

 11.1.2.2 const 메서드

 클래스 메서드를 const로 지정할 수 있다. 그러면 그 클래스에서 mutable로 선언하지 않은 데이터 멤버는 변경할 수 없다. 구체적인 예는 9장을 참고한다.

 

 11.1.2.3 constexpr 키워드

 constexpr 키워드를 함수나 메서드에 사용하면 상수 표현식으로 다시 정의해준다. 이때 컴파일러가 그 함수를 컴파일 시간에 평가(evaluation)해야 할 뿐만 아니라 부작용(size effect)이 발생하면 안 되기 때문에 많은 제약 사항이 적용된다.

  • constexpr 함수의 리턴 타입은 반드시 리터럴 타입이어야 한다.
  • constexpr 함수의 매개 변수는 반드시 리터럴 타입이어야 한다.
  • 클래스의 멤버가 constexpr 함수일 때 virtual로 선언할 수 없다.
  • 함수 본문에 goto, try/catch, 초기화되지 않은 변수, 리터럴 타입이 아닌 변수 등이 있으면 안 된다.
  • new와 delete를 사용할 수 없다.

 사용자 정의 타입으로 된 상수 표현식 변수를 만들고 싶다면 constexpr 생성자를 정의한다. 생성자에 constexpr을 적용할 때도 여러 가지 제약 사항이 적용된다.

  • 가상 부모 클래스를 가질 수 없다.
  • 데이터 멤버를 상수 표현식으로 초기화해야 한다.
  • 생성자의 매개 변수가 반드시 리터럴 타입이어야 한다.
  • 생성자 본문을 constexpr 함수 본문과 똑같은 요구 사항을 만족해야 한다.

 예를 들어, 다음 코드에 나오는 Rect 클래스는 위 요구 사항에 맞게 constexpr 생성자를 정의하고 있다.

class Rect {
public:
	constexpr Rect(size_t width, size_t height) : mWidth(width), mHeight(height) { }
	constexpr size_t getArea() const {
		return mWidth * mHeight;
	}
private:
	size_t mWidth, mHeight;
};

constexpr Rect rect(10, 20);
int intArray[rect.getArea()];

 

11.2.2 static 키워드

 11.2.2.1 static 데이터 멤버·static 메서드

 static으로 선언한 데이터 멤버는 객체에 속하지 않는다. 다시 말해 static 데이터 멤버는 객체 외부에 단 하나만 존재한다. static 메서드도 마찬가지로 객체가 아닌 클래스에 속한다. static 데이터 멤버와 메서드는 9장을 참고한다.

 

 11.2.2.2 static 링크

① 외부 링크와 내부 링크

 C++는 코드를 소스 파일 단위로 컴파일해서 그 결과로 나온 오브젝트 파일들을 링크 단계에서 서로 연결한다. 소스 파일마다 정의된 이름은 외부 링크나 내부 링크를 통해 서로 연결된다. 외부 링크(external linkage)로 연결되면 다른 소스 파일에서 이름을 사용할 수 있고, 내부 링크(internal linkage)로 연결되면 같은 파일에서만 사용할 수 있다. 예를 들어 First.cpp와 Second.cpp란 소스 파일이 있다고 하자. First.cpp 파일은 f를 선언만 하고 이를 정의하는 코드는 없다.

/* First.cpp */
void f();

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

 Second.cpp 파일은 f를 선언하는 코드와 정의하는 코드가 모두 있다.

/* Second.cpp */
#include <iostream>
void f(); /* f가 외부 링크로 처리됩니다. */
void f() {
	std::cout << "f\n";
}

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

 이번에는 Second.cpp에서 f의 선언문 앞에 static을 붙이거나 익명 네임스페이스(anonymous namespace)를 이용해보자.

/* Second.cpp */
#include <iostream>
static void f(); /* f가 내부 링크로 처리됩니다. */
void f() {
	std::cout << "f\n";
}

/* Second.cpp */
#include <iostream>
namespace {
	void f(); /* f가 내부 링크로 처리됩니다. */
	void f() {
		std::cout << "f\n";
	}
}

 이렇게 수정하면 링크 단계에서 에러가 발생한다. f가 내부 링크로 변경되어 First.cpp에서 찾을 수 없기 때문이다. 마찬가지로 익명 네임스페이스에 속한 항목은 이를 선언한 소스 파일 안에서는 얼마든지 접근할 수 있지만 다른 소스 파일에서는 접근할 수 없다.

CAUTION 내부 링크로 지정할 때는 static 키워드보다는 익명 네임스페이스를 사용하는 것이 바람직하다.

 

② extern 키워드

 어떤 이름을 extern으로 선언하면 컴파일러는 이를 정의가 아닌 선언문으로 취급한다. 변수를 extern으로 지정하면 컴파일러는 그 변수에 대해 메모리를 할당하지 않으므로 그 변수를 정의하는 문장을 따로 작성해야 한다.

/* AnotherFile.cpp */
extern int x;
int x = 10;
또는
extern int x = 10;

 extern이 반드시 필요한 경우는 FirstFile.cpp와 같은 다른 소스 파일에서 x에 접근하게 만들 때다. extern을 붙여서 전역 변수로 만들면 여러 소스 파일에서 공유할 수 있다.

/* FirstFile.cpp */
#include <iostream>
extern int x;

int main(void) {
	std::cout << x << std::endl;// 10
	return 0;
}

 FirstFile.cpp에서 x를 extern으로 선언했기 때문에 다른 파일에 있는 x를 여기서 사용할 수 있다.

 

 11.2.2.3 함수 안에 static 변수

 함수 안에서 static으로 지정한 변수는 그 함수만 접근할 수 있는 전역 변수와 같다. 예를 들면 다음과 같다. 이러한 static 키워드의 용도는 특정한 스코프 안에서만 값을 유지하는 지역 변수를 만드는 것이다.

void performTask() {
	static bool initialized = false;
	if(!initialized) {
		cout << "초기화 중..." << endl;
		initialized = true;
	}
	// 작업을 수행합니다.
}

 

11.2.3 비로컬 변수의 초기화 순서

 C++ 표준은 비지역(non-local) 변수가 여러 소스 파일에 선언되었을 때 초기화하는 순서를 따로 정해두지 않았다. 일반적으로는 어느 것이 먼저 초기화되든 상관없지만, 간혹 전역 변수나 static 변수가 서로 의존 관계에 있을 때 문제가 발생할 수 있다. 예를 들어, 어떤 전역 객체의 생성자 안에서 다른 전역 객체에 접근할 수 있는데, 그러기 위해서는 그 객체가 이미 생성되어 있어야 한다. 하지만 두 소스 파일에 정의된 전역 객체 중 어느 것이 먼저 생성되고 초기화될지 알 수 없다.

CAUTION 여러 소스 파일에 선언된 비지역 변수의 초기화 순서는 C++ 표준에 정의되어 있지 않다.

 

11.2.4 비로컬 변수의 소멸 순서

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

댓글