'4.1 레슨 — 기본 데이터 타입 소개'에서는 C++ 언어 자체에서 코어 언어의 일부로 제공하는 기본 데이터 타입(fundamental data types) 에 대해 알아보았습니다.
지금까지 우리는 프로그램에서 이런 기본 타입들, 특히 int 타입을 아주 많이 사용해 왔습니다. 기본 타입들은 간단한 작업에는 엄청나게 유용하지만, 조금 더 복잡한 작업을 시작하게 되면 우리가 필요로 하는 모든 것을 해결해 주지는 못합니다.
예를 들어, 두 분수를 곱하는 수학 프로그램을 만든다고 상상해 보세요. 프로그램에서 분수는 어떻게 표현할 수 있을까요? 아마도 아래처럼 두 개의 정수(하나는 분자용, 하나는 분모용)를 짝지어 사용할 수 있을 것입니다.
#include <iostream>
int main(){
// 첫 번째 분수
int num1 {};
int den1 {};
// 두 번째 분수
int num2 {};
int den2 {};
// 분자와 분모 사이의 슬래시(/)를 무시(제거)하기 위해 사용
char ignore {};
std::cout << "Enter a fraction: ";
std::cin >> num1 >> ignore >> den1;
std::cout << "Enter a fraction: ";
std::cin >> num2 >> ignore >> den2;
std::cout << "The two fractions multiplied: "
<< num1 * num2 << '/' << den1 * den2 << '\n';
return 0;
}
프로그램의 실행 결과는 다음과 같습니다:
Enter a fraction: 1/2
Enter a fraction: 3/4
The two fractions multiplied: 3/8
이 프로그램은 잘 작동하지만, 앞으로 개선해야 할 몇 가지 문제점이 있습니다.
첫째, 각 정수 쌍이 아주 느슨하게만 연결되어 있다는 점입니다. 주석이나 코드가 사용된 문맥을 제외하면, 분자와 분모 쌍이 서로 관련되어 있다는 것을 알기 어렵습니다.
둘째, '똑같은 코드를 반복하지 말라'는 DRY(Don't Repeat Yourself) 원칙에 따라, 우리는 사용자가 분수를 입력하는 과정을 (오류 처리와 함께) 담당할 함수를 만들어야 합니다. 하지만 함수는 단 하나의 값만 반환할 수 있는데, 어떻게 분자와 분모 두 개의 값을 함수를 호출한 곳으로 돌려줄 수 있을까요?
자, 이번에는 직원들의 ID 목록을 저장해야 하는 프로그램을 작성한다고 상상해 봅시다. 어떻게 해야 할까요? 아마 다음과 같이 시도할 수 있을 겁니다.
int main(){
int id1 { 42 };
int id2 { 57 };
int id3 { 162 };
// 기타 등등 계속...
}
하지만 직원이 100명이라면 어떨까요? 우선 100개의 변수 이름을 일일이 타이핑해야 할 겁니다. 게다가 그 변수들을 전부 화면에 출력해야 하거나 함수로 전달해야 한다면요? 아마 코드를 치느라 엄청난 고생을 하게 될 것입니다. 이런 방식은 데이터가 많아질 때 감당하기 힘듭니다(확장성이 없습니다).
분명히 기본 데이터 타입만으로는 한계가 있습니다.
다행히도 C++은 두 번째 데이터 타입 그룹인 복합 데이터 타입(때로는 합성 데이터 타입이라고도 함)을 지원합니다.
복합 데이터 타입은 이미 존재하는 다른 데이터 타입들을 바탕으로 정의되는 타입입니다.
이 타입들은 특정 종류의 문제를 해결하는 데 유용한 추가적인 특성과 동작 방식을 가지고 있습니다.
핵심 포인트
모든 데이터 타입은 '기본 타입' 아니면 '복합 타입'입니다. C++ 언어 표준은 각 타입이 어느 범주에 속하는지 명확하게 정의하고 있습니다.
이번 장과 앞으로의 장들에서 배우겠지만, 우리는 복합 데이터 타입을 사용하여 위에서 언급한 모든 문제들을 아주 깔끔하게 해결할 수 있습니다.
C++은 다음과 같은 복합 타입들을 지원합니다.
| 큰 분류 (Category) | 세부 분류 (Subcategory) |
|---|---|
| 함수 (Functions) | 함수 (Functions) |
| C 스타일 배열 (C-style Arrays) | C 스타일 배열 (C-style Arrays) |
| 포인터 타입 (Pointer types) | 객체 포인터 (Pointer to object) |
| 포인터 타입 (Pointer types) | 함수 포인터 (Pointer to function) |
| 멤버 포인터 타입 (Pointer to member types) | 데이터 멤버 포인터 (Pointer to data member) |
| 멤버 포인터 타입 (Pointer to member types) | 멤버 함수 포인터 (Pointer to member function) |
| 참조 타입 (Reference types) | L-value 참조 (L-value references) |
| 참조 타입 (Reference types) | R-value 참조 (R-value references) |
| 열거형 타입 (Enumerated types) | 범위 없는 열거형 (Unscoped enumerations) |
| 열거형 타입 (Enumerated types) | 범위 있는 열거형 (Scoped enumerations) |
| 클래스 타입 (Class types) | 구조체 (Structs) |
| 클래스 타입 (Class types) | 클래스 (Classes) |
| 클래스 타입 (Class types) | 공용체 (Unions) |
사실 여러분은 이미 하나의 복합 타입을 규칙적으로 사용해 오고 있습니다. 바로 '함수'입니다. 예를 들어 다음과 같은 함수를 생각해 보세요.
void doSomething(int x, double y){}
이 함수의 타입은 void(int, double)입니다. 이 타입이 여러 기본 타입들로 구성되어 있다는 점에 주목하세요. 그래서 복합 타입인 것입니다. 물론 함수는 자신만의 특별한 동작(예: 호출 가능성)도 가지고 있습니다.
다뤄야 할 내용이 아주 많기 때문에, 우리는 여러 장에 걸쳐서 학습할 예정입니다.
이번 장에서는 L-value 참조와 포인터를 포함하여 비교적 간단한 복합 타입들을 다루겠습니다.
다음 장에서는 범위 없는 열거형, 범위 있는 열거형, 그리고 첫 번째 클래스 타입인 '구조체(structs)'를 배울 것입니다.
그 이후의 장들에서는 '클래스(classes)'를 소개하고 더 유용한 '배열(array)' 타입들에 대해 깊이 파고들 것입니다.
여기에는 std::string(5.7 레슨에서 소개됨)도 포함되는데, 이것도 사실 클래스 타입 중 하나랍니다!
용어 사전 (Nomenclature)
'클래스 타입(class type)'이란 구조체(struct), 클래스(class), 또는 공용체(union)를 의미하는 타입입니다. 앞으로의 레슨에서 이 용어를 아주 많이 사용하게 될 것입니다.
우리가 배울 첫 번째 복합 타입인 'lvalue 참조(reference)'에 대해 이야기하기 전에, 잠시 길을 벗어나 lvalue가 대체 무엇인지부터 알아보겠습니다.
이전 레슨(1.10 - 표현식 소개)에서 우리는 표현식(expression)을 "하나의 값을 만들어내기 위해 실행할 수 있는 리터럴, 변수, 연산자, 함수 호출의 조합"으로 정의했습니다.
예를 들어보겠습니다.
#include <iostream>
int main()
{
std::cout << 2 + 3 << '\n'; // 2 + 3 표현식은 5라는 값을 만들어냅니다.
return 0;
}
위 프로그램에서 2 + 3이라는 표현식은 평가(계산)되어 5라는 값을 만들고, 그 값이 콘솔 화면에 출력됩니다.
또한 레슨 6.4(증감 연산자와 부수 효과)에서는 표현식이 끝난 후에도 그 결과가 계속 남아있는 '부수 효과(side effect)'를 만들 수 있다는 점도 배웠습니다.
#include <iostream>
int main()
{
int x { 5 };
++x; // 이 표현식 구문은 x의 값을 1 증가시키는 부수 효과를 가집니다.
std::cout << x << '\n'; // 6을 출력합니다.
return 0;
}
위 프로그램에서 ++x 표현식은 x의 값을 증가시키며, 이 변경된 값은 표현식의 실행이 완전히 끝난 뒤에도 그대로 유지됩니다.
표현식은 값을 만들거나 부수 효과를 일으키는 것 외에도 한 가지 일을 더 할 수 있습니다. 바로 어떤 객체나 함수 그 자체로 해석(평가)되는 것입니다. 이에 대해서는 곧 자세히 살펴보겠습니다.
컴파일러가 표현식을 어떻게 계산해야 하는지, 그리고 코드의 어느 위치에 쓸 수 있는지 판단하기 위해 C++의 모든 표현식은 두 가지 속성을 가집니다. 바로 타입(type)과 값 카테고리(value category)입니다.
표현식의 타입은 그 표현식을 계산했을 때 나오는 값, 객체, 또는 함수의 데이터 타입과 같습니다.
int main()
{
auto v1 { 12 / 4 }; // 정수 / 정수 => 정수(int)
auto v2 { 12.0 / 4 }; // 실수 / 정수 => 실수(double)
return 0;
}
v1의 경우, 컴파일러는 두 개의 int(정수)를 나누면 int 결과가 나온다는 것을 컴파일할 때 미리 파악합니다.
따라서 이 표현식의 타입은 int이며, 타입 추론(auto)을 통해 v1의 타입도 int로 정해집니다.
v2의 경우, double(실수)과 int를 나누면 double 결과가 나옵니다. 산술 연산자는 양쪽의 타입이 같아야 하므로 int가 double로 변환되어 실수 나눗셈이 진행됩니다. 따라서 이 표현식의 타입은 double이 됩니다.
컴파일러는 이 '표현식의 타입'을 보고, 현재 작성된 코드가 문법에 맞는지 확인합니다.
#include <iostream>
void print(int x)
{
std::cout << x << '\n';
}
int main()
{
print("foo"); // 오류: print()는 int형 인자를 기대하지만, 문자열 리터럴을 전달하려고 했습니다.
return 0;
}
위 코드에서 print(int) 함수는 정수형 매개변수를 기대합니다. 하지만 우리가 전달한 표현식(문자열 "foo")의 타입은 정수가 아니며 정수로 변환할 방법도 없기 때문에 컴파일 에러가 발생합니다.
핵심 포인트
표현식의 타입은 반드시 컴파일 시간에 결정되어야 합니다(그래야 타입 검사와 추론이 정상 작동합니다). 하지만 표현식의 값 자체는 상황에 따라 컴파일 시간(constexpr인 경우)에 결정될 수도 있고, 프로그램이 실행되는 런타임에 결정될 수도 있습니다.
이제 다음 프로그램을 살펴봅시다.
int main()
{
int x{};
x = 5; // 유효함: x에 5를 대입할 수 있습니다.
5 = x; // 오류: 리터럴 값 5에 x의 값을 대입할 수 없습니다.
return 0;
}
이 두 개의 대입문 중 하나는 정상이고 하나는 에러가 납니다(숫자 5에 변수 x를 넣는다는 건 말이 안 되니까요). 그렇다면 컴파일러는 대입 연산자(=)의 양쪽 끝에 어떤 표현식이 올 수 있는지 어떻게 알 수 있을까요?
그 해답이 바로 표현식의 두 번째 속성인 값 카테고리에 있습니다. 표현식의 값 카테고리는 해당 표현식이 값으로 해석되는지, 함수로 해석되는지, 아니면 특정한 형태의 객체로 해석되는지를 나타냅니다.
C++11 이전에는 딱 두 가지 카테고리만 존재했습니다. 바로 lvalue와 rvalue입니다.
작성자의 노트
C++11부터는 '이동 의미론(move semantics)'이라는 기능을 지원하기 위해 세 가지 추가 카테고리(glvalue, prvalue, xvalue)가 생겼습니다. 하지만 이 레슨에서는 초보자가 훨씬 쉽게 이해할 수 있도록 과거 기준인 lvalue와 rvalue로만 설명하겠습니다. 당장은 이 두 가지만 알아도 충분하며, 나머지 복잡한 카테고리는 나중에 다루겠습니다.
lvalue는 식별 가능한 객체나 함수로 해석되는 표현식입니다.
C++ 표준에서 말하는 "식별성(identity)"이란, 메모리 주소 등을 통해 다른 비슷한 녀석들과 딱 꼬집어 구별할 수 있다는 뜻입니다.
이렇게 식별 가능한 객체는 이름(식별자), 참조, 포인터 등을 통해 접근할 수 있고, 일반적으로 코드가 한 줄 실행되고 끝나는 것보다 더 오랫동안 메모리에 살아남습니다.
int main()
{
int x { 5 };
int y { x }; // x는 lvalue 표현식입니다.
return 0;
}
위 코드에서 표현식 x는 이름이 있는 변수 x 그 자체를 가리키므로 lvalue 표현식입니다.
언어에 상수가 도입되면서 lvalue는 두 가지 하위 종류로 나뉘게 되었습니다.
값을 수정할 수 있는 수정 가능한 lvalue와, const나 constexpr이 붙어있어 값을 바꿀 수 없는 수정 불가능한 lvalue입니다.
int main()
{
int x{};
const double d{};
int y { x }; // x는 수정 가능한 lvalue 표현식입니다.
const double e { d }; // d는 수정 불가능한 lvalue 표현식입니다.
return 0;
}
rvalue는 lvalue가 아닌 모든 표현식을 뜻합니다. rvalue 표현식은 하나의 일회성 '값'으로 해석됩니다.
흔히 볼 수 있는 rvalue로는 우리가 직접 적는 숫자나 문자 같은 리터럴(단, lvalue로 취급되는 C 스타일 문자열 리터럴은 제외)과 값을 반환하는 함수의 결과값 등이 있습니다. rvalue는 따로 이름표가 없어서 식별이 불가능하므로 생성되자마자 즉시 사용해야 하며, 해당 표현식이 실행되는 그 짧은 순간에만 존재합니다.
int return5()
{
return 5;
}
int main()
{
int x{ 5 }; // 5는 rvalue 표현식입니다.
const double d{ 1.2 }; // 1.2는 rvalue 표현식입니다.
int y { x }; // x는 수정 가능한 lvalue 표현식입니다.
const double e { d }; // d는 수정 불가능한 lvalue 표현식입니다.
int z { return5() }; // return5()는 rvalue 표현식입니다. (결과가 값으로 반환되기 때문)
int w { x + 1 }; // x + 1은 rvalue 표현식입니다.
int q { static_cast<int>(d) }; // d를 int로 정적 형변환(static cast)한 결과는 rvalue 표현식입니다.
return 0;
}
왜 return5(), x + 1, static_cast<int>(d)가 rvalue인지 궁금하실 텐데요. 그 이유는 이 표현식들이 식별 가능한 실체(객체)를 나타내는 것이 아니라, 임시로 쓰이고 버려질 값을 만들어내기 때문입니다.
핵심 포인트
- lvalue 표현식: 식별 가능한 '객체'를 나타냅니다.
- rvalue 표현식: 임시적인 '값'을 나타냅니다.
특별한 언급이 없는 한, C++의 연산자들은 기본적으로 피연산자(계산의 대상)가 rvalue일 것을 기대합니다.
예를 들어 더하기 연산자(+)는 양쪽 모두 rvalue가 오길 기다립니다.
#include <iostream>
int main()
{
std::cout << 1 + 2; // 1과 2는 rvalue이며, + 연산자는 계산 결과로 rvalue를 반환합니다.
return 0;
}
리터럴 1과 2는 모두 rvalue 표현식입니다. + 연산자는 이 값들을 가져와 3이라는 새로운 rvalue 표현식을 기꺼이 반환해 줍니다.
이제 아까 보았던 x = 5는 되고 5 = x는 안 되는 이유를 명확히 대답할 수 있습니다. 대입 연산자(=)는 왼쪽에 반드시 '수정 가능한 lvalue 표현식'이 와야 합니다. 5 = x가 실패하는 이유는 왼쪽의 5가 수정 가능한 lvalue가 아니라 단순한 rvalue 값이기 때문입니다.
int main()
{
int x{};
// 대입 연산은 왼쪽 피연산자로 수정 가능한 lvalue를, 오른쪽 피연산자로 rvalue를 요구합니다.
x = 5; // 유효함: x는 수정 가능한 lvalue이고, 5는 rvalue입니다.
5 = x; // 오류: 5는 rvalue이고, x는 수정 가능한 lvalue입니다.
return 0;
}
대입 연산자는 오른쪽에 rvalue(값)가 오기를 기대한다고 했습니다. 그렇다면 다음 코드는 왜 문제없이 실행되는 걸까요?
int main()
{
int x{ 1 };
int y{ 2 };
x = y; // y는 rvalue가 아니지만, 이 코드는 유효합니다.
return 0;
}
rvalue가 필요한 자리에 lvalue가 주어지면, C++은 그 상황에 맞춰 lvalue를 쓰기 위해 lvalue-to-rvalue 변환을 조용히 수행합니다.
쉽게 말해 메모리에 저장된 lvalue 객체를 읽어들여서 알맹이 '값(rvalue)'만 쏙 뽑아낸다는 뜻입니다.
위 예제에서 lvalue인 y는 변환을 거쳐 2라는 rvalue 값을 만들어내고, 그 값이 최종적으로 x에 대입됩니다.
핵심 포인트
- lvalue는 rvalue로 자연스럽게(암시적으로) 변환될 수 있습니다. 즉, rvalue가 필요한 자리에는 어디든 lvalue를 쓸 수 있습니다.
- 반면, rvalue는 lvalue로 변환되지 않습니다.
이제 다음 예제를 보겠습니다.
int main()
{
int x { 2 };
x = x + 1;
return 0;
}
이 한 줄의 구문에서 변수 x는 서로 다른 두 가지 역할로 쓰이고 있습니다. 대입 연산자(=)의 왼쪽에서는 변수 공간 그 자체를 가리키는 lvalue로 쓰입니다. 반대로 오른쪽에서는 덧셈을 하기 위해 x 안에 들어있던 값(2)을 뽑아내는 변환을 거쳐 rvalue로 쓰입니다. + 연산자는 3이라는 rvalue를 반환하고, 최종적으로 이 값이 다시 왼쪽의 lvalue x에 저장됩니다.
여전히 어떤 것이 lvalue이고 rvalue인지 헷갈리실 수 있습니다.
예를 들어, ++ 기호를 붙인 결과는 lvalue일까요, 아니면 rvalue일까요? 이를 구분하는 몇 가지 방법을 소개합니다.
팁 (간단한 판별법)
- lvalue 표현식: 함수나 식별 가능한 객체(변수 등)를 나타내며, 이 코드가 끝난 다음 줄에서도 메모리에 계속 살아남습니다.
- rvalue 표현식: 리터럴이나 임시 객체처럼 하나의 '값'으로 쓰이고, 해당 줄의 코드가 끝나면 흔적도 없이 사라집니다.
가장 확실하게 아는 방법은 컴파일러에게 직접 물어보는 코드를 짜는 것입니다.
다음은 어떤 표현식이 lvalue인지 rvalue인지 판별해 주는 흥미로운 코드입니다.
#include <iostream>
#include <string>
// T&는 lvalue 참조이므로, lvalue에 대해서는 이 오버로딩 함수가 우선적으로 선택됩니다.
template <typename T>
constexpr bool is_lvalue(T&)
{
return true;
}
// T&&는 rvalue 참조이므로, rvalue에 대해서는 이 오버로딩 함수가 우선적으로 선택됩니다.
template <typename T>
constexpr bool is_lvalue(T&&)
{
return false;
}
// 도우미 매크로 (#expr은 expr에 전달된 내용을 텍스트 그대로 출력해 줍니다)
#define PRINTVCAT(expr) { std::cout << #expr << " is an " << (is_lvalue(expr) ? "lvalue\n" : "rvalue\n"); }
int getint() { return 5; }
int main()
{
PRINTVCAT(5); // rvalue
PRINTVCAT(getint()); // rvalue
int x { 5 };
PRINTVCAT(x); // lvalue
PRINTVCAT(std::string {"Hello"}); // rvalue
PRINTVCAT("Hello"); // lvalue
PRINTVCAT(++x); // lvalue
PRINTVCAT(x++); // rvalue
}
출력 결과는 다음과 같습니다.
5 is an rvalue
getint() is an rvalue
x is an lvalue
std::string {"Hello"} is an rvalue
"Hello" is an lvalue
++x is an lvalue
x++ is an rvalue
이 판별법은 C++의 오버로딩(중복 정의) 기능을 활용한 것입니다.
인자로 lvalue가 들어오면 lvalue 전용 함수가, rvalue가 들어오면 rvalue 전용 함수가 선택되는 원리입니다.
출력 결과를 보면 재미있는 사실을 알 수 있습니다.
증감 연산자는 앞에 붙이면(전위 연산자, ++x) lvalue를 반환하지만, 뒤에 붙이면(후위 연산자, x++) rvalue를 반환한다는 것입니다!
심화 학습 독자를 위해
숫자를 비롯한 일반 리터럴들은 모두 rvalue입니다. 하지만"Hello"같은 C 스타일 문자열 리터럴은 특이하게도 lvalue입니다. C 스타일 문자열(문자 배열)은 내부적으로 포인터로 변환되는 특징을 가집니다. 메모리 주소가 있어야만 포인터로 변환이 가능하기 때문에 이 배열은 lvalue여야만 합니다. C++은 오래된 C 언어 코드와의 호환성을 유지하기 위해 이 특징을 그대로 물려받았습니다. (배열 붕괴에 대해서는 나중에 17.8 레슨에서 다루겠습니다.)
자, 이제 lvalue가 무엇인지 이해했으니 우리의 첫 번째 복합 타입인 lvalue 참조(lvalue reference)를 배울 준비가 끝났습니다!
C++에서 참조(reference)는 이미 존재하는 객체(변수 등)에 붙이는 '별명(alias)'입니다. 한 번 참조를 정의하고 나면, 그 참조를 통해 하는 모든 작업은 실제 가리키고 있는 원본 객체에 똑같이 적용됩니다. 즉, 참조를 사용해 원본 객체의 값을 읽거나 수정할 수 있습니다.
처음에는 참조가 굳이 왜 필요한지, 쓸모없거나 불필요해 보일 수 있습니다. 하지만 참조는 C++ 전반에 걸쳐 아주 많이 사용됩니다. (앞으로 다가올 레슨들에서 그 예시를 보게 될 것입니다.)
핵심 포인트
참조는 본질적으로 가리키는 대상(원본 객체)과 완전히 동일하다고 생각하시면 됩니다. 함수에 대한 참조도 만들 수 있지만, 자주 쓰이진 않습니다. 모던 C++에는 두 가지 종류의 참조가 있습니다. 바로 'lvalue 참조'와 'rvalue 참조'입니다. 이번 장에서는 lvalue 참조에 대해 알아봅니다.
lvalue 참조(C++11 이전에는 참조가 하나뿐이었기 때문에 보통 그냥 "참조"라고 부릅니다)는 기존의 lvalue(보통 변수)에 대한 별명 역할을 합니다.
객체의 타입이 어떤 종류의 값을 담을 수 있는지 결정하듯, 참조의 타입은 어떤 타입의 객체를 가리킬 수 있는지를 결정합니다.
Lvalue 참조 타입은 타입 이름 뒤에 앰퍼샌드 기호(&)를 하나 붙여서 표시합니다.
// 일반적인 타입들
int // 일반 int 타입 (참조 아님)
int& // int 객체에 대한 lvalue 참조
double& // double 객체에 대한 lvalue 참조
const int& // const int 객체에 대한 lvalue 참조
예를 들어, int&는 int 타입의 객체를 가리키는 lvalue 참조 타입이고, const int&는 const int 타입의 객체를 가리키는 lvalue 참조 타입입니다.
이때 int&처럼 참조를 명시하는 타입을 참조 타입(reference type)이라고 부르고, int처럼 참조가 가리키는 원본 대상을 참조되는 타입(referenced type)이라고 부릅니다.
용어 정리
Lvalue 참조에는 두 가지 종류가 있습니다:
1.const가 아닌 lvalue 참조는 보통 그냥 "lvalue 참조"라고 부릅니다. 명확히 구분하기 위해 non-const lvalue 참조라고 부르기도 합니다.
2.const인 lvalue 참조는 보통 const lvalue 참조라고 부릅니다.이번 레슨에서는 non-const lvalue 참조에 집중하고, const lvalue 참조는 다음 레슨(12.4)에서 다루겠습니다.
lvalue 참조 타입을 사용해 할 수 있는 일 중 하나는 lvalue 참조 변수를 만드는 것입니다.
이는 lvalue(보통 다른 변수)에 대한 참조 역할을 하는 변수입니다.
간단히 lvalue 참조 타입으로 변수를 정의하면 됩니다.
#include <iostream>
int main()
{
int x { 5 }; // x는 일반 정수 변수입니다.
int& ref { x }; // ref는 이제 변수 x의 별명으로 사용할 수 있는 lvalue 참조 변수입니다.
std::cout << x << '\n'; // x의 값을 출력 (5)
std::cout << ref << '\n'; // ref를 통해 x의 값을 출력 (5)
return 0;
}
위 예제에서 int& 타입은 ref를 int형에 대한 참조로 정의하고, 이를 변수 x에 연결(초기화)합니다.
그 후에는 ref와 x를 똑같은 변수처럼 사용할 수 있습니다. 따라서 이 프로그램의 출력 결과는 다음과 같습니다:
5
5
컴파일러 입장에서는 앰퍼샌드(&)가 타입 이름에 붙든(int& ref), 변수 이름에 붙든(int &ref) 상관하지 않습니다. 어떤 방식을 쓸지는 온전히 코딩 스타일의 문제입니다. 다만, 모던 C++ 프로그래머들은 참조가 변수 이름의 일부가 아니라 '타입 정보의 일부'라는 것을 명확히 하기 위해 타입 이름 쪽에 앰퍼샌드를 붙이는 것을 선호합니다.
모범 사례
참조를 정의할 때 앰퍼샌드(&)는 변수 이름이 아니라 타입 이름 옆에 붙이세요.
고급 독자를 위한 팁
포인터에 이미 익숙한 분들에게 덧붙이자면, 이 문맥에서 앰퍼샌드(&)는 "메모리 주소"를 의미하는 것이 아닙니다. "lvalue 참조"를 의미합니다.
위 예제에서 참조를 사용해 원본 객체의 값을 읽어오는 방법을 보았습니다.
마찬가지로, non-const 참조를 이용해 가리키고 있는 대상의 값을 수정할 수도 있습니다.
#include <iostream>
int main()
{
int x { 5 }; // 일반 정수 변수
int& ref { x }; // ref는 이제 변수 x의 별명입니다.
std::cout << x << ref << '\n'; // 55 출력
x = 6; // x의 값은 이제 6입니다.
std::cout << x << ref << '\n'; // 66 출력
ref = 7; // 참조하는 객체(x)의 값이 이제 7로 바뀝니다.
std::cout << x << ref << '\n'; // 77 출력
return 0;
}
이 코드의 출력 결과는 다음과 같습니다:
55
66
77
ref는 x의 완벽한 별명이므로, x를 직접 수정하든 ref를 통해 수정하든 결국 똑같이 x의 값이 바뀝니다.
상수(constant)와 마찬가지로, 모든 참조는 반드시 초기화되어야 합니다.
참조는 '참조 초기화(reference initialization)'라는 방식을 통해 초기화됩니다.
int main()
{
int& invalidRef; // 오류: 참조는 반드시 초기화되어야 합니다.
int x { 5 };
int& ref { x }; // 정상: int 참조가 int 변수에 연결되었습니다.
return 0;
}
참조가 어떤 객체(또는 함수)에 연결될 때, 우리는 참조가 그 객체에 바인딩(bound)되었다고 표현합니다.
이 연결 과정을 참조 바인딩(reference binding)이라고 부르며, 대상이 되는 원본 객체를 참조 대상(referent)이라고 부릅니다.
Non-const lvalue 참조는 수정 가능한 lvalue에만 연결될 수 있습니다.
int main()
{
int x { 5 };
int& ref { x }; // 정상: 수정 가능한 lvalue에 non-const lvalue 참조가 바인딩됨
const int y { 5 };
int& invalidRef { y }; // 오류: 수정 불가능한 lvalue에는 non-const lvalue 참조를 바인딩할 수 없음
int& invalidRef2 { 0 }; // 오류: rvalue에는 non-const lvalue 참조를 바인딩할 수 없음
return 0;
}
핵심 포인트
만약 수정 불가능한 상수(const)나 rvalue에 참조를 연결할 수 있다면, 참조를 통해 그 값을 마음대로 바꿔버릴 수 있게 됩니다.
이는 상수의 의미를 망가뜨리는 것이므로 C++에서는 이를 막고 있습니다.
(void타입에 대한 참조 역시 허용되지 않습니다. 애초에 의미가 없으니까요.)
참조의 타입(예: int&)과 대상 객체의 타입(예: int)이 문자 그대로 똑같지 않더라도, 여기서는 형변환(conversion)이 일어나지 않습니다.
타입의 차이는 참조 초기화 과정에서 내부적으로 처리됩니다.
대부분의 경우, 참조는 자신의 '참조되는 타입'과 일치하는 타입의 객체에만 연결될 수 있습니다.
(나중에 상속을 배울 때 일부 예외를 다룰 것입니다.)
만약 타입이 일치하지 않는 객체에 참조를 연결하려고 시도하면,
컴파일러는 객체를 해당 타입으로 몰래 변환(암시적 변환)한 뒤에 연결하려고 시도합니다.
핵심 포인트
하지만 변환되어 나온 결과물은 rvalue가 됩니다. 앞서 말했듯 non-const lvalue 참조는 rvalue에 연결될 수 없으므로, 결국 타입이 맞지 않는 객체에 연결하려 하면 컴파일 오류가 발생합니다.
int main()
{
int x { 5 };
int& ref { x }; // 정상: 참조하는 타입(int)이 초기화 값의 타입과 일치함
double d { 6.0 };
int& invalidRef { d }; // 오류: double을 int로 변환하는 것은 데이터 손실 우려가 있어 중괄호 초기화에서 금지됨
double& invalidRef2 { x }; // 오류: x를 double로 변환한 결과는 rvalue이므로 non-const lvalue 참조에 바인딩할 수 없음
return 0;
}
한 번 초기화되고 나면, C++의 참조는 다시 연결(reseated)될 수 없습니다. 즉, 다른 객체를 가리키도록 중간에 변경할 수 없습니다.
초보자분들은 종종 할당 연산자(=)를 사용해 참조가 다른 변수를 가리키게 만들려고 시도합니다. 코드는 문제없이 컴파일되고 실행되지만, 예상과는 전혀 다르게 동작합니다. 아래 프로그램을 확인해 보세요:
#include <iostream>
int main()
{
int x { 5 };
int y { 6 };
int& ref { x }; // ref는 이제 x의 별명입니다.
ref = y; // y의 값(6)을 x(ref가 참조하고 있는 객체)에 할당합니다.
// 위 코드는 ref가 y를 가리키도록 대상을 변경하는 것이 아닙니다!
std::cout << x << '\n'; // 사용자는 이 코드가 5를 출력할 것이라고 예상할 수 있습니다.
return 0;
}
이 코드는 다음을 출력합니다.
6
코드에서 참조가 사용되면, 이는 가리키고 있는 대상 객체로 치환됩니다. 따라서 ref = y는 ref의 방향을 y로 바꾸는 것이 아닙니다.
ref는 x의 별명이므로 x = y라고 쓴 것과 완전히 똑같이 작동합니다. y의 값이 6이기 때문에 결국 x에 6이 덮어씌워진 것입니다.
참조 변수 역시 일반 변수와 똑같은 범위와 수명 규칙을 따릅니다.
#include <iostream>
int main()
{
int x { 5 }; // 일반 정수
int& ref { x }; // 변수 값에 대한 참조
return 0;
} // x와 ref는 여기서 소멸됩니다.
(다음 레슨에서 다룰 한 가지 예외를 제외하면) 참조의 수명과 원본 객체의 수명은 서로 아무런 관련이 없습니다.
즉, 다음 두 가지가 모두 가능합니다:
참조가 원본 객체보다 먼저 소멸되더라도 원본 객체에는 아무런 영향을 주지 않습니다. 아래 예시를 보세요.
#include <iostream>
int main()
{
int x { 5 };
{
int& ref { x }; // ref는 x에 대한 참조입니다.
std::cout << ref << '\n'; // ref의 값을 출력 (5)
} // ref는 여기서 소멸됩니다. x는 이 사실을 알지 못합니다.
std::cout << x << '\n'; // x의 값을 출력 (5)
return 0;
} // x는 여기서 소멸됩니다.
위 코드는 다음을 출력합니다:
5
5
ref가 블록을 벗어나 소멸되더라도, 변수 x는 누군가 자신을 가리키던 참조가 사라졌다는 사실을 전혀 모른 채 평소처럼 잘 살아있습니다.
만약 원본 객체가 참조보다 먼저 파괴되어 사라져 버리면 어떻게 될까요?
참조는 이미 존재하지도 않는 객체를 허공에 가리키고 있게 됩니다. 이런 상태의 참조를 허상 참조(dangling reference)라고 부릅니다.
이 허상 참조에 접근해 값을 읽거나 쓰려고 시도하면 예측할 수 없는 심각한 오류(정의되지 않은 동작)가 발생합니다. 허상 참조를 만드는 것은 피하기 쉬운 편이지만, 실제 프로그래밍에서 어떻게 이런 일이 일어나는지는 12.12 - 참조로 반환하기 레슨에서 보여드리겠습니다.
다소 의외일 수 있지만, C++에서 참조는 엄밀히 말해 '객체(object)'가 아닙니다.
참조는 물리적인 메모리 공간을 차지할 필요가 없습니다. 가능하다면 컴파일러는 참조가 쓰인 모든 부분을 실제 원본 객체로 대체해 버리고 참조 자체를 흔적도 없이 최적화해 지워버립니다. (물론 항상 가능한 것은 아니며, 상황에 따라 공간을 차지할 수도 있습니다.)
이러한 사실 때문에 "참조 변수"라는 말은 약간 어색한 표현이기도 합니다.
변수란 '이름을 가진 객체'인데, 참조는 애초에 객체가 아니기 때문입니다.
참조는 객체가 아니므로, 객체가 꼭 들어가야 하는 자리에는 참조를 사용할 수 없습니다. 예를 들어, '참조에 대한 참조'는 만들 수 없습니다.
Lvalue 참조는 오직 식별 가능한 '객체'만 가리킬 수 있기 때문입니다. 만약 진짜 객체처럼 다룰 수 있는 참조나, 중간에 대상을 바꿀 수 있는(reseat) 참조가 꼭 필요하다면 std::reference_wrapper(23.3장에서 다룸)를 사용하면 됩니다.
참고로...
다음 변수들을 한번 살펴보세요:int var{}; int& ref1{ var }; // var에 바인딩된 lvalue 참조 int& ref2{ ref1 }; // var에 바인딩된 lvalue 참조
ref2가 참조인ref1로 초기화되었기 때문에,ref2가 '참조에 대한 참조'라고 착각하기 쉽습니다. 하지만 아닙니다.ref1은var의 별명이므로, 수식에서 사용될 때ref1은 결국var로 취급됩니다. 따라서ref2역시 그저var에 직접 연결된 평범한 참조일 뿐입니다.
진짜로 참조에 대한 참조를 만들려면int&&같은 문법을 써야겠지만, C++은 이를 지원하지 않습니다. 대신 이 문법은 C++11부터 rvalue 참조(22.2장에서 다룸)를 나타내는 용도로 완전히 새롭게 정의되었습니다.
작성자의 노트
지금 단계에서는 참조가 조금 쓸모없어 보일지도 모르겠습니다. 하지만 전혀 걱정하지 마세요. 참조는 앞으로 엄청나게 많이 쓰일 것이며, 왜 그렇게 중요한지는 곧 다가올 12.5 - lvalue 참조로 전달하기와 12.6 - const lvalue 참조로 전달하기에서 확실히 알게 되실 겁니다.
이전 레슨(12.3 - Lvalue 참조)에서는 Lvalue 참조가 오직 '수정 가능한 Lvalue'에만 연결(바인딩, bind)될 수 있다는 점을 배웠습니다.
즉, 아래와 같이 코드를 작성하면 오류가 발생합니다.
int main()
{
const int x { 5 }; // x는 수정 불가능한 (const) Lvalue입니다.
int& ref { x }; // 오류: ref는 수정 불가능한 Lvalue에 바인딩될 수 없습니다.
return 0;
}
이 코드가 허용되지 않는 이유는 간단합니다.
만약 이게 가능하다면, 일반 참조(ref)를 사용해 상수로 선언된 변수(x)의 값을 몰래 바꿀 수 있게 되기 때문입니다.
그렇다면 상수(const) 변수를 참조하고 싶을 때는 어떻게 해야 할까요? 일반적인 Lvalue 참조로는 불가능합니다.
Lvalue 참조를 선언할 때 const 키워드를 함께 사용하면, 이 참조가 가리키는 대상을 '수정할 수 없는 상태(const)'로 취급하라고 컴파일러에게 알려주게 됩니다. 이를 const 값에 대한 Lvalue 참조(간단히 'const 참조'라고도 부릅니다)라고 합니다.
이러한 const Lvalue 참조는 수정 불가능한 Lvalue에 정상적으로 연결될 수 있습니다.
int main()
{
const int x { 5 }; // x는 수정 불가능한 Lvalue입니다.
const int& ref { x }; // 정상: ref는 const 값에 대한 Lvalue 참조입니다.
return 0;
}
const Lvalue 참조는 가리키는 대상을 상수로 취급하기 때문에, 값을 읽을(접근할) 수는 있지만 값을 수정할 수는 없습니다.
#include <iostream>
int main()
{
const int x { 5 }; // x는 수정 불가능한 Lvalue입니다.
const int& ref { x }; // 정상: ref는 const 값에 대한 Lvalue 참조입니다.
std::cout << ref << '\n'; // 정상: const 객체에 접근(읽기)할 수 있습니다.
ref = 6; // 오류: const 참조를 통해 객체의 값을 수정할 수는 없습니다.
return 0;
}
const Lvalue 참조는 '수정 가능한 Lvalue'에도 연결될 수 있습니다.
이 경우, 원본 객체는 수정이 가능하더라도 이 참조(ref)를 통해 접근할 때만큼은 상수로 취급됩니다.
#include <iostream>
int main()
{
int x { 5 }; // x는 수정 가능한 Lvalue입니다.
const int& ref { x }; // 정상: const 참조를 수정 가능한 Lvalue에 바인딩할 수 있습니다.
std::cout << ref << '\n'; // 정상: const 참조를 통해 객체에 접근할 수 있습니다.
ref = 7; // 오류: const 참조를 통해서는 객체를 수정할 수 없습니다.
x = 6; // 정상: 원본 x는 수정 가능한 Lvalue이므로, 원래 이름을 사용하면 여전히 값을 바꿀 수 있습니다.
return 0;
}
위 프로그램에서 우리는 const 참조인 ref를 수정 가능한 Lvalue인 x에 연결했습니다.
ref를 통해 x의 값을 읽을 수는 있지만, ref 자체가 const이기 때문에 ref를 사용해 x의 값을 바꿀 수는 없습니다.
하지만 원본 변수인 x를 직접 사용하면 여전히 값을 바꿀 수 있습니다.
핵심 포인트
참조하는 대상을 직접 수정해야 하는 상황이 아니라면, 일반 Lvalue 참조보다는 const Lvalue 참조를 사용하는 습관을 들이는 것이 좋습니다.
놀랍게도, const Lvalue 참조는 rvalue(숫자 5와 같은 값 자체)에도 연결될 수 있습니다.
#include <iostream>
int main()
{
const int& ref { 5 }; // 정상: 5는 rvalue입니다.
std::cout << ref << '\n'; // 5가 출력됩니다.
return 0;
}
이런 코드가 실행되면, 내부적으로 rvalue 값을 담은 임시 객체(temporary object)가 하나 만들어집니다.
그리고 우리의 const 참조는 바로 그 임시 객체에 연결되는 것입니다.
const Lvalue 참조는 심지어 타입이 다른 값에도 연결될 수 있습니다.
단, 그 값이 참조의 타입으로 알아서 변환(암시적 변환)될 수 있어야 합니다.
#include <iostream>
int main()
{
// 상황 1
const double& r1 { 5 }; // 값 5로 초기화된 임시 double 객체가 만들어지고, r1은 그 임시 객체에 바인딩됩니다.
std::cout << r1 << '\n'; // 5가 출력됩니다.
// 상황 2
char c { 'a' };
const int& r2 { c }; // 문자 'a'로 초기화된 임시 int 객체가 만들어지고, r2는 그 임시 객체에 바인딩됩니다.
std::cout << r2 << '\n'; // 97이 출력됩니다 (r2는 int형 참조이기 때문입니다).
return 0;
}
double 타입의 임시 객체가 생성되고 정수 5로 초기화됩니다. 그런 다음 r1이 이 임시 double 객체에 연결됩니다.int 타입의 임시 객체가 생성되고 문자 'a'로 초기화됩니다. 그런 다음 r2가 이 임시 int 객체에 연결됩니다.두 경우 모두 참조의 타입과 새롭게 만들어진 임시 객체의 타입이 완벽히 일치하게 됩니다.
핵심 포인트
만약 여러분이 const Lvalue 참조를 다른 타입의 값에 연결하려고 하면, 컴파일러는 참조와 똑같은 타입의 임시 객체를 새로 만듭니다.
그 후 값을 넣어 초기화하고 참조를 그 임시 객체에 연결해버립니다.상황 2에서
r2를 출력할 때 문자가 아닌 숫자가 나오는 이유도 이 때문입니다.
r2는 원본 문자c를 참조하는 것이 아니라, 새롭게 만들어진 임시int객체를 참조하고 있기 때문입니다.
⚠️ 경고 (Warning)
우리는 보통 "참조 = 원본 객체"라고 생각합니다. 하지만 참조가 원본이 아닌 '임시 복사본'에 연결되어 버리면 이 공식이 깨집니다.
이 상태에서는 원본을 수정해도 참조된 값은 바뀌지 않으며, 그 반대도 마찬가지입니다. (서로 완전히 다른 객체이기 때문입니다.)이게 왜 문제가 되는지 보여주는 엉뚱한 예시를 하나 보겠습니다.
#include <iostream> int main() { short bombs { 1 }; // 나 폭탄 가질 수 있어! (참고: 타입은 short입니다) const int& you { bombs }; // 너도 폭탄 가질 수 있어 (참고: 타입은 int& 입니다) --bombs; // 폭탄 다 썼어 if (you) // 너 아직 가지고 있어? { std::cout << "폭탄 투하! 안녕, 잔혹한 세상아.\n"; // 폭탄 춤을 추자 } return 0; }위 예제에서
bombs는short타입이고,you는const int&타입입니다.you는int에만 연결될 수 있기 때문에, 컴파일러는bombs를int로 변환하여 값 1을 가진 새로운 임시int객체를 만들어버립니다. 결국you는 원본bombs가 아니라 이 임시 객체에 연결됩니다.나중에
bombs의 개수를 줄여도(--bombs;),you는 전혀 영향을 받지 않습니다. 가리키는 대상이 다르기 때문입니다. 그
래서 우리는if (you)가 거짓(false)이 될 거라 예상하지만, 실제로는 참(true)이 되어버립니다.
일반적으로 임시 객체는 자신이 만들어진 코드 줄(표현식)이 끝날 때 메모리에서 사라집니다(파괴됩니다).
만약 const int& ref { 5 }; 라는 코드에서 숫자 5를 담고 있는 임시 객체가 이 줄이 끝날 때 바로 사라진다면 어떻게 될까요?
참조 변수인 ref는 이미 파괴된 대상을 가리키는 위험한 상태(이를 댕글링 참조, dangling reference라고 합니다)가 되고, 나중에 ref를 사용하려고 하면 프로그램이 비정상적으로 작동할 것입니다.
이를 막기 위해 C++에는 특별한 규칙이 있습니다. const Lvalue 참조가 임시 객체에 직접 바인딩되면, 그 임시 객체의 수명이 참조 변수의 수명과 똑같아지도록 연장됩니다.
#include <iostream>
int main()
{
const int& ref { 5 }; // 값 5를 가진 임시 객체는 ref와 똑같이 살아남도록 수명이 연장됩니다.
std::cout << ref << '\n'; // 따라서 여기서 안전하게 사용할 수 있습니다.
return 0;
} // ref와 임시 객체 모두 여기서 수명을 다하고 파괴됩니다.
이 규칙 덕분에 우리는 임시 객체가 중간에 사라질 걱정 없이 다음 줄에서도 안전하게 값을 출력하고 사용할 수 있습니다.
핵심 포인트
일반 Lvalue 참조는 오직 '수정 가능한 Lvalue'에만 연결할 수 있습니다.
하지만 const Lvalue 참조는 수정 가능한 Lvalue, 수정 불가능한 Lvalue, 그리고 rvalue까지 모두 연결할 수 있습니다.
이 때문에 훨씬 더 유연하게 활용할 수 있는 참조 방식입니다.
참조에 constexpr 키워드를 적용하면, 해당 참조를 상수 표현식(constant expression) 안에서 사용할 수 있게 됩니다.
하지만 이 constexpr 참조에는 큰 제약이 하나 있습니다. 바로 전역 변수(globals)나 정적 지역 변수(static locals)처럼 프로그램 내내 메모리 위치가 고정되어 있는(static duration) 객체에만 연결할 수 있다는 점입니다.
일반 지역 변수는 함수가 호출되기 전까지는 메모리 주소를 알 수 없기 때문에 constexpr 참조를 연결할 수 없습니다.
int g_x { 5 };
int main()
{
[[maybe_unused]] constexpr int& ref1 { g_x }; // 정상: 전역 변수에 바인딩할 수 있습니다.
static int s_x { 6 };
[[maybe_unused]] constexpr int& ref2 { s_x }; // 정상: 정적(static) 지역 변수에 바인딩할 수 있습니다.
int x { 6 };
[[maybe_unused]] constexpr int& ref3 { x }; // 컴파일 오류: 정적이 아닌 일반 객체에는 바인딩할 수 없습니다.
return 0;
}
const 변수에 대해 constexpr 참조를 정의할 때는, constexpr(참조 자체에 적용)과 const(참조하는 타입에 적용)를 모두 적어주어야 합니다.
int main()
{
static const int s_x { 6 }; // const int 변수
[[maybe_unused]] constexpr const int& ref2 { s_x }; // constexpr과 const가 모두 필요합니다.
return 0;
}
이러한 까다로운 제한 사항들 때문에 실무에서 constexpr 참조가 쓰이는 일은 거의 없습니다.
이전 강의들에서는 lvalue 참조와 const에 대한 lvalue 참조를 배웠습니다. 사실 이것들만 보면 크게 유용해 보이지 않았을 수 있어요.
"그냥 변수를 쓰면 되지 왜 굳이 변수의 별명을 만들까?" 하고 생각하셨을 텐데요.
이번 강의에서는 드디어 참조(reference)가 왜 유용한지 명쾌하게 알려드릴게요!
이 챕터 후반부부터는 참조가 아주 자주 쓰이는 걸 보시게 될 겁니다
먼저 배경지식을 복습해 볼까요? 2.4 강의에서 우리는 값으로 전달하기(pass by value)를 배웠습니다.
함수에 인자를 넘길 때, 그 값이 함수의 매개변수로 '복사'되는 방식이죠.
#include <iostream>
void printValue(int y)
{
std::cout << y << '\n';
} // y는 여기서 소멸됩니다.
int main()
{
int x { 2 };
printValue(x); // x는 매개변수 y로 '값으로 전달(복사)'됩니다. (비용이 적게 듦)
return 0;
}
위 프로그램에서 printValue(x)를 호출하면, x의 값(2)이 매개변수 y로 복사됩니다. 그리고 함수가 끝날 때 객체 y는 파괴(소멸)되죠.
즉, 함수를 부를 때 우리가 넣은 값의 복사본을 만들어 두고서, 잠깐 쓰고 바로 버린다는 뜻입니다!
다행히 기본 자료형(int 등)은 복사하는 데 비용(시간과 메모리)이 거의 안 들어서 이건 별문제가 안 됩니다.
std::string처럼 표준 라이브러리에서 제공하는 대부분의 타입은 '클래스(class) 타입'입니다.
클래스 타입은 보통 복사하는 데 비용이 많이 듭니다.
그래서 우리는 가능하면 복사 비용이 비싼 객체를 굳이 복사하는 일은 피하는 게 좋습니다.
복사본을 만들자마자 거의 바로 파괴할 거라면 더더욱요.
이 점을 잘 보여주는 다음 프로그램을 살펴볼까요?
#include <iostream>
#include <string>
void printValue(std::string y)
{
std::cout << y << '\n';
} // y는 여기서 소멸됩니다.
int main()
{
std::string x { "Hello, world!" }; // x는 std::string 타입입니다.
printValue(x); // x는 매개변수 y로 '값으로 전달(복사)'됩니다. (비용이 많이 듦)
return 0;
}
이 코드는 Hello, world!를 출력합니다.
이 프로그램은 우리가 원하는 대로 잘 동작하지만, 아주 비효율적입니다.
이전 예제와 똑같이 printValue()를 부를 때 인자 x가 매개변수 y로 복사됩니다.
하지만 이번에는 인자가 int가 아니라 std::string이고, std::string은 복사 비용이 비싼 클래스 타입이죠.
게다가 printValue()를 호출할 때마다 이 비싼 복사 작업이 매번 일어납니다!
우린 이것보다 더 똑똑하게 코드를 짤 수 있어요.
함수를 부를 때 이런 비싼 복사 과정을 피하는 한 가지 방법은 '값으로 전달' 대신 '참조로 전달(pass by reference)'을 사용하는 것입니다.
참조로 전달할 때는 함수의 매개변수를 일반 타입이 아니라 참조 타입(또는 const 참조 타입)으로 선언합니다.
그러면 함수가 호출될 때 각 참조 매개변수가 알맞은 인자에 '연결(bind)'됩니다.
참조는 원래 인자의 별명 역할을 하므로, 복사가 전혀 일어나지 않습니다.
위의 예제를 '값으로 전달' 대신 '참조로 전달' 방식으로 바꿔보겠습니다.
#include <iostream>
#include <string>
void printValue(std::string& y) // 타입이 std::string& 로 변경되었습니다.
{
std::cout << y << '\n';
} // y는 여기서 소멸됩니다.
int main()
{
std::string x { "Hello, world!" };
printValue(x); // x는 이제 참조 매개변수 y로 '참조로 전달'됩니다. (비용이 적게 듦)
return 0;
}
이 프로그램은 앞선 예제와 완벽히 똑같지만, 매개변수 y의 타입이 std::string에서 std::string&(lvalue 참조)로 바뀌었다는 점만 다릅니다.
이제 printValue(x)가 호출되면, lvalue 참조 매개변수 y는 인자 x에 바로 연결됩니다. 참조를 연결하는 건 항상 비용이 아주 저렴하고, x를 복사할 필요도 없죠. 참조는 연결된 객체의 별명 역할을 하므로, printValue() 안에서 참조 y를 쓰면 사실상 복사본이 아닌 진짜 인자 x에 바로 접근하는 셈입니다.
핵심 포인트
참조로 전달하기를 사용하면, 함수를 호출할 때마다 인자를 복사하지 않고도 함수에 데이터를 넘겨줄 수 있습니다.
아래 프로그램은 '값 매개변수'가 원래 인자와 아예 다른 별개의 객체인 반면, '참조 매개변수'는 원래 인자와 완전히 똑같은 객체로 취급된다는 걸 명확히 보여줍니다.
#include <iostream>
void printAddresses(int val, int& ref)
{
std::cout << "값 매개변수의 주소는: " << &val << '\n';
std::cout << "참조 매개변수의 주소는: " << &ref << '\n';
}
int main()
{
int x { 5 };
std::cout << "x의 주소는: " << &x << '\n';
printAddresses(x, x);
return 0;
}
이 프로그램을 실행하면 대략 다음과 같은 결과가 나옵니다.
0x7ffd16574de00x7ffd16574de40x7ffd16574de0보시다시피, 원래 인자(x)와 값 매개변수(val)의 메모리 주소가 다릅니다. 즉, 둘은 서로 다른 별개의 객체라는 뜻이죠.
두 메모리 공간이 따로 떨어져 있기 때문에, 값 매개변수가 원래 인자와 같은 값을 가지려면 어쩔 수 없이 인자의 값을 복사해 와야만 합니다.
반면에 참조 매개변수(ref)의 주소를 확인해 보면 원래 인자와 주소가 완벽히 똑같습니다.
이는 참조 매개변수가 사실상 원래 인자와 동일한 객체로 취급되고 있다는 뜻입니다.
객체를 '값으로 전달'하면, 함수 매개변수는 인자의 '복사본'을 받게 됩니다.
즉, 함수 안에서 매개변수의 값을 아무리 지지고 볶아도 원본은 멀쩡하고 복사본만 바뀐다는 뜻입니다.
#include <iostream>
void addOne(int y) // y는 x의 복사본입니다.
{
++y; // 이것은 실제 객체 x가 아니라 복사본을 수정합니다.
}
int main()
{
int x { 5 };
std::cout << "value = " << x << '\n';
addOne(x);
std::cout << "value = " << x << '\n'; // x는 수정되지 않았습니다.
return 0;
}
위 프로그램에서 값 매개변수 y는 x의 복사본이기 때문에, y에 1을 더해봤자 y만 바뀔 뿐입니다.
그래서 두 번 모두 value = 5가 출력됩니다.
하지만 참조는 참조하는 대상 객체와 완전히 똑같이 작동하므로, 참조로 전달했을 때는 매개변수에 변화를 주면 원본 인자도 똑같이 변하게 됩니다.
#include <iostream>
void addOne(int& y) // y는 실제 객체 x에 연결됩니다.
{
++y; // 이것은 실제 객체 x를 수정합니다.
}
int main()
{
int x { 5 };
std::cout << "value = " << x << '\n';
addOne(x);
std::cout << "value = " << x << '\n'; // x가 수정되었습니다.
return 0;
}
이 프로그램을 실행하면 결과가 다릅니다.
처음에 x의 값은 5입니다. addOne(x)가 호출되면서 참조 매개변수 y가 인자 x에 바로 연결되죠.
함수 안에서 참조 y를 1 증가시키면, 사실상 복사본이 아니라 진짜 x를 5에서 6으로 증가시키는 셈입니다.
이렇게 바뀐 값은 addOne() 함수가 끝난 후에도 그대로 유지됩니다.
핵심 포인트
값을 const가 아닌(non-const) 참조로 전달하면, 함수 안에서 넘어온 원본 인자의 값을 직접 수정할 수 있습니다.
함수가 넘겨받은 원본의 값을 직접 바꿀 수 있다는 건 아주 유용합니다. 예를 들어, 몬스터가 플레이어를 성공적으로 공격했는지 판정하는 함수를 만들었다고 해볼까요? 공격이 성공했다면 플레이어의 체력을 깎아야겠죠. 이때 플레이어 객체를 '참조로 전달'하면, 함수가 진짜 플레이어 객체의 체력을 직접 깎을 수 있습니다. 만약 '값으로 전달'했다면 엉뚱하게 복사본의 체력만 깎일 테니 아무 소용이 없었을 겁니다.
const가 아닌 일반 값에 대한 참조는 오직 수정 가능한 lvalue(쉽게 말해 const가 아닌 일반 변수)에만 연결될 수 있습니다.
그래서 '참조로 전달하기' 역시 수정 가능한 lvalue 인자하고만 쓸 수 있죠.
이 규칙 때문에 const가 아닌 참조 전달은 실무에서 쓰임새가 꽤 제한됩니다.
const 변수나 5 같은 숫자(리터럴)는 인자로 넘길 수가 없거든요. 예를 들면 이렇습니다.
#include <iostream>
void printValue(int& y) // y는 수정 가능한 lvalue만 받습니다.
{
std::cout << y << '\n';
}
int main()
{
int x { 5 };
printValue(x); // 성공: x는 수정 가능한 lvalue입니다.
const int z { 5 };
printValue(z); // 오류: z는 수정 불가능한 lvalue입니다.
printValue(5); // 오류: 5는 rvalue입니다.
return 0;
}
다행히 이 문제를 해결할 아주 쉬운 방법이 있는데요, 이건 다음 강의에서 다루겠습니다.
그때 '값으로 전달하기'와 '참조로 전달하기' 중 어떤 걸 언제 써야 할지도 함께 알아볼게요!
const가 아닌 일반 참조는 수정 가능한 lvalue에만 연결될 수 있습니다. 반면, const 참조는 수정 가능한 lvalue, 수정 불가능한 lvalue, 그리고 rvalue(우측값) 모두에 연결될 수 있습니다. 따라서 참조 매개변수를 const로 만들면, 어떤 종류의 인수(argument)를 넘겨주더라도 모두 받을 수 있게 됩니다.
#include <iostream>
void printRef(const int& y) // y는 const 참조입니다.
{
std::cout << y << '\n';
}
int main()
{
int x { 5 };
printRef(x); // 성공: x는 수정 가능한 lvalue이며, y는 x에 연결됩니다.
const int z { 5 };
printRef(z); // 성공: z는 수정 불가능한 lvalue이며, y는 z에 연결됩니다.
printRef(5); // 성공: 5는 rvalue 리터럴이며, y는 임시 int 객체에 연결됩니다.
return 0;
}
const 참조로 값을 전달하면 일반 참조 전달의 가장 큰 장점인 ‘인수의 복사를 방지하는 것’은 그대로 가져가면서, 동시에 함수 내부에서 ‘참조된 값을 절대 변경할 수 없도록 보장’해 줍니다.
예를 들어, 아래 코드에서 ref는 const이므로 값을 증가시키는 코드는 허용되지 않습니다.
void addOne(const int& ref)
{
++ref; // 허용되지 않음: ref는 const입니다.
}
대부분의 경우, 우리는 함수가 원본 인수의 값을 마음대로 바꾸는 것을 원치 않습니다.
모범 사례 (Best practice)
함수가 인수의 값을 반드시 변경해야 하는 특별한 이유가 없다면, 일반 참조보다는 const 참조를 사용하는 것을 항상 우선시하세요.
이제 왜 C++에서 const lvalue 참조가 rvalue와 연결되도록 허용했는지 그 진짜 이유를 알 수 있습니다.
만약 이 기능이 없었다면, 참조를 사용하는 함수에 리터럴 숫자(예: 5)나 다른 rvalue들을 직접 전달할 방법이 전혀 없었을 테니까요!
이전 레슨에서 배웠듯, const lvalue 참조는 참조 타입으로 변환할 수만 있다면 아예 다른 타입의 값에도 연결될 수 있습니다.
이 과정에서 변환된 '임시 객체'가 하나 만들어지고, 참조 매개변수는 그 임시 객체에 연결됩니다.
이 기능을 허용한 주된 이유는, 값으로 전달하든 const 참조로 전달하든 똑같이 편하게 인수를 넘길 수 있게 만들기 위해서입니다.
#include <iostream>
void printVal(double d)
{
std::cout << d << '\n';
}
void printRef(const double& d)
{
std::cout << d << '\n';
}
int main()
{
printVal(5); // 5가 임시 double로 변환된 후 매개변수 d에 복사됨
printRef(5); // 5가 임시 double로 변환된 후 매개변수 d에 연결됨
return 0;
}
값으로 전달(printVal)할 때는 어차피 복사가 일어날 거라고 예상하기 때문에, 중간에 타입 변환이 한 번 일어나서 추가 복사가 생겨도 큰 문제가 되지 않습니다 (게다가 컴파일러가 알아서 불필요한 복사는 최적화로 없애주곤 합니다).
하지만 참조로 전달(printRef)할 때는 보통 복사가 일어나는 것을 피하려고 사용하는 경우가 많습니다. 그런데 타입이 달라서 변환이 발생하면, 결과적으로 비용이 큰 복사 작업이 숨어서 일어날 수 있으므로 성능에 좋지 않을 수 있습니다.
경고 (Warning)
참조로 전달할 때는 넘겨주는 인수의 타입과 참조의 타입이 정확히 일치하는지 확인하세요.
그렇지 않으면 예상치 못한 비싼 변환 작업이 일어날 수 있습니다.
매개변수가 여러 개인 함수는 각각의 매개변수를 값으로 받을지, 참조로 받을지 따로따로 정할 수 있습니다.
#include <string>
void foo(int a, int& b, const std::string& c)
{
}
int main()
{
int x { 5 };
const std::string s { "Hello, world!" };
foo(5, x, s);
return 0;
}
위 예제에서 첫 번째 인수(5)는 값으로, 두 번째 인수(x)는 참조로, 세 번째 인수(s)는 const 참조로 전달되고 있습니다.
대부분의 C++ 초보자에게는 어떤 걸 값으로 넘기고 어떤 걸 참조로 넘길지 결정하는 것이 꽤 헷갈립니다.
다행히 대부분의 상황에서 통하는 아주 간단한 기준이 있습니다.
int, double 같은 기본 타입(Fundamental types)이나 열거형(Enum)은 복사하는 데 드는 비용이 매우 저렴합니다. 따라서 보통 값으로 전달합니다.std::string 같은 클래스 타입(Class types)은 복사하는 데 비용이 많이 들 수 있습니다. 따라서 보통 const 참조로 전달합니다.모범 사례 (Best practice)
기본 규칙으로 기본 타입은 값으로, 클래스 타입은 const 참조로 전달하세요.
무엇을 써야 할지 잘 모르겠다면 일단 const 참조로 전달하는 것이 예상치 못한 문제를 피하는 가장 안전한 길입니다.
팁 (Tip)
알아두면 좋은 몇 가지 예외적인 경우들입니다.
다음은 효율성을 위해 보통 값으로 전달합니다.
- 열거형 타입 (enum)
- 뷰(Views)와 스팬(Spans) (예:
std::string_view,std::span)- 참조나 포인터 역할을 대신하는 타입 (예: 반복자(iterators),
std::reference_wrapper)- 복사 비용이 아주 저렴한 단순한 클래스 타입 (예: 기본 타입을 담은
std::pair,std::optional,std::expected)다음은 참조로 전달해야 합니다.
- 함수 내부에서 값이 변경되어야 하는 인수
- 구조상 복사할 수 없는 타입 (예:
std::ostream)- 복사하면 소유권 문제가 생기는 타입 (예:
std::unique_ptr,std::shared_ptr)- 가상 함수를 가지거나 상속을 목적으로 하는 타입 (객체 슬라이싱이라는 문제를 피하기 위해)
앞서 std::string_view처럼 클래스 타입인데도 값으로 전달하는 예외를 보셨을 겁니다.
그렇다면 그냥 모든 것을 무조건 참조로 전달하면 안 되는 걸까요? 이 섹션에서는 두 방식의 진짜 비용을 비교해 봅니다.
첫째, 함수 매개변수를 초기화하는 비용을 생각해야 합니다.
값 전달에서 초기화란 곧 복사를 뜻합니다. 복사 비용은 다음 두 가지에 따라 커집니다.
반면, 참조를 객체에 연결하는 작업은 항상 기본 타입을 복사하는 것만큼 매우 빠릅니다.
둘째, 함수 매개변수를 사용하는 비용을 생각해야 합니다. 작은 값을 값으로 전달하면, 컴파일러는 이를 느린 RAM 대신 접근 속도가 빠른 CPU 레지스터에 보관하는 최적화를 할 수 있습니다.
값 매개변수를 사용할 때는 복사된 데이터가 있는 곳(레지스터나 RAM)에 한 번만 직접 접근하면 됩니다. 하지만 참조 매개변수를 쓸 때는 보통 단계가 하나 더 추가됩니다. 프로그램이 참조 변수가 있는 곳을 먼저 찾아간 다음, 그 참조가 가리키고 있는 진짜 객체가 있는 RAM 공간을 다시 찾아가야 합니다. 즉, 값 매개변수 사용이 1번의 접근이라면, 참조 매개변수 사용은 2번의 접근이 필요합니다.
셋째, 최적화 측면입니다. 컴파일러는 여러 개의 포인터나 참조가 같은 데이터를 가리킬 수 있는 상황(에일리어싱, Aliasing)에서는 혹시 모를 버그를 막기 위해 아주 보수적으로 코드를 최적화합니다. 하지만 값 전달은 완전히 독립된 복사본을 만들기 때문에 이런 걱정 없이 아주 적극적인 최적화가 가능합니다.
이제 왜 모든 걸 참조로 넘기지 않는지 답이 나왔습니다.
그렇다면 "복사 비용이 싸다"는 기준은 뭘까요?
대략적으로 객체의 크기가 메모리 주소 크기의 2배 이하이며, 특별한 추가 설정 비용이 없는 객체라면 복사 비용이 싸다고 봅니다.
아래 코드는 어떤 타입이 복사 비용이 싼지 판별하는 간단한 매크로 예제입니다.
#include <iostream>
// 타입(또는 객체)의 크기가 메모리 주소 2개 크기 이하이면 true를 반환하는 함수형 매크로
#define isSmall(T) (sizeof(T) <= 2 * sizeof(void*))
struct S
{
double a;
double b;
double c;
};
int main()
{
std::cout << std::boolalpha; // 1이나 0 대신 true나 false로 출력
std::cout << isSmall(int) << '\n'; // 참 (true)
double d {};
std::cout << isSmall(d) << '\n'; // 참 (true)
std::cout << isSmall(S) << '\n'; // 거짓 (false)
return 0;
}
참고 (As an aside)
일반적인 C++ 함수는 타입 자체를 인수로 받을 수 없기 때문에, 객체나 타입 이름 모두 넣을 수 있도록 전처리기 매크로를 사용했습니다. 다만 어떤 클래스에 '추가 설정 비용'이 있는지는 눈으로 봐서 알기 어렵습니다. 잘 모르겠다면 C++ 표준 라이브러리 클래스들은 일단 추가 설정 비용이 있다고 안전하게 가정하는 것이 좋습니다.
현대 C++를 배우다 보면 항상 마주치는 질문이 있습니다.
"문자열을 받을 때 const std::string&를 써야 하나요, 아니면 std::string_view를 써야 하나요?"
대부분의 경우 std::string_view가 더 나은 선택입니다. 훨씬 다양한 형태의 문자열 인수를 효율적으로 처리할 수 있기 때문입니다.
또한, 긴 문자열 중 일부(부분 문자열)만 잘라서 넘길 때도 새로운 문자열을 복사할 필요가 없어 성능에 아주 좋습니다.
void doSomething(const std::string&);
void doSomething(std::string_view); // 대부분의 경우 이 방식을 권장합니다.
단, 예외적으로 const std::string&를 써야 하는 상황도 있습니다:
std::string_view가 없는 경우.std::string을 넘겨받아야 하는 다른 외부 함수를 또 호출해야 하는 경우. (std::string_view는 C스타일 함수가 기대하는 문자열의 끝(null-terminated)을 보장하지 않으며, 다시 std::string으로 변환하려면 꽤 비효율적입니다.)모범 사례 (Best practice)
특별히 다른 구형 함수와 호환을 맞춰야 하는 상황이 아니라면, 문자열은const std::string&대신std::string_view를 값으로 전달하세요.
C++에서 문자열 인수는 보통 다음 세 가지 형태로 넘어옵니다.
std::stringstd::string_view컴파일러는 함수 매개변수 타입과 내가 넣은 인수의 타입이 다르면 알아서 임시 객체를 만들어 변환(Implicit conversion)을 시도합니다. 이때 std::string_view를 만드는 것은 문자열 원본을 복사하지 않기 때문에 아주 저렴하지만, std::string을 만드는 것은 문자열 전체를 통째로 복사하기 때문에 아주 비싼 작업이 됩니다.
| 인수 타입 (Argument) | std::string_view 매개변수 | const std::string& 매개변수 |
|---|---|---|
| std::string | 비용이 저렴한 변환 | 비용이 저렴한 참조 연결 |
| std::string_view | 비용이 저렴한 복사 | std::string으로의 비싼 명시적 변환 |
| C스타일 문자열/리터럴 | 비용이 저렴한 변환 | 비싼 변환 |
위 표에서 보시듯, std::string_view 매개변수는 어떤 타입의 문자열을 넣어도 저렴하게 처리합니다. 반면 const std::string& 매개변수는 똑같은 std::string을 넘길 때만 빠르고, 다른 형태의 문자열이 들어오면 내부적으로 비싼 문자열 복사가 일어납니다.
#include <iostream>
#include <string>
#include <string_view>
void printSV(std::string_view sv)
{
std::cout << sv << '\n';
}
void printS(const std::string& s)
{
std::cout << s << '\n';
}
int main()
{
std::string s{ "Hello, world" };
std::string_view sv { s };
// std::string_view 매개변수에 전달
printSV(s); // 성공: std::string에서 std::string_view로의 저렴한 변환
printSV(sv); // 성공: std::string_view의 저렴한 복사
printSV("Hello, world"); // 성공: C스타일 문자열 리터럴에서 std::string_view로의 저렴한 변환
// const std::string& 매개변수에 전달
printS(s); // 성공: std::string 인수에 저렴하게 연결됨
printS(sv); // 컴파일 에러: std::string_view를 std::string으로 암시적 변환할 수 없음
printS(static_cast<std::string>(sv)); // 나쁨: std::string 임시 객체를 생성하는 비싼 작업
printS("Hello, world"); // 나쁨: std::string 임시 객체를 생성하는 비싼 작업
return 0;
}
또한 함수 내부에서 사용할 때도 차이가 납니다. std::string_view는 일반 객체라 문자열에 곧바로 접근할 수 있지만, std::string&는 참조형이라 실제 데이터에 접근하기 위해 메모리를 한 다리 더 건너가야 합니다.
마지막으로 원본 문자열의 일부(부분 문자열)만 떼어서 넘기고 싶을 때, std::string_view를 쓰면 복사 없이 아주 빠르고 저렴하게 처리할 수 있어 성능 최적화에 유리합니다.
포인터(Pointers)는 C++를 배울 때 많은 초보자들이 두려워하고 막히는 '전통적인 골칫거리' 중 하나입니다.
하지만 곧 알게 되겠지만, 포인터는 전혀 무서워할 필요가 없습니다.
사실 포인터는 lvalue 레퍼런스(참조)와 매우 비슷하게 동작합니다.
그 부분을 자세히 설명하기 전에, 먼저 기초 개념부터 다져보겠습니다.
다음과 같은 아주 평범한 변수를 생각해 봅시다.
char x {}; // char 타입은 1바이트의 메모리를 사용합니다.
조금 단순하게 설명하자면, 이 코드가 실행될 때 컴퓨터는 RAM이라는 메모리 공간의 일부를 이 변수(객체)에 할당합니다. 이해를 돕기 위해 변수 x에 140번지 메모리 주소가 할당되었다고 가정해 보겠습니다. 앞으로 프로그램에서 x라는 변수를 사용할 때마다, 프로그램은 알아서 140번지 메모리로 찾아가 그곳에 저장된 값을 읽어옵니다.
변수가 참 편리한 이유는, 우리가 특정 메모리 주소가 몇 번지인지, 객체의 값을 저장하는 데 몇 바이트가 필요한지 신경 쓸 필요가 없다는 것입니다. 그저 우리가 지어준 이름(식별자)으로 변수를 부르기만 하면, 컴파일러가 그 이름을 적절한 메모리 주소로 번역해 줍니다.
컴파일러가 복잡한 주소 처리를 다 알아서 해주는 셈이죠.
이러한 특징은 레퍼런스에도 똑같이 적용됩니다.
int main(){
char x {}; // 이 변수에 140번지 메모리 주소가 할당되었다고 가정합시다.
char& ref { x }; // ref는 x에 대한 lvalue 레퍼런스입니다. (타입과 함께 쓰일 때 &는 lvalue 레퍼런스를 의미합니다)
return 0;
}
ref는 x의 별명처럼 작동하기 때문에, 우리가 ref를 사용할 때마다 프로그램은 동일하게 140번지 메모리로 가서 값을 읽어옵니다.
여기서도 역시 주소를 찾아가는 일은 컴파일러가 알아서 처리합니다.
&)기본적으로 변수가 사용하는 메모리 주소는 우리 눈에 보이지 않게 숨겨져 있지만, 원한다면 이 정보에 접근할 수 있습니다.
주소 연산자(&)는 해당 변수의 실제 메모리 주소를 반환합니다. 사용법은 아주 직관적입니다.
#include <iostream>
int main(){
int x{ 5 };
std::cout << x << '\n'; // 변수 x의 값을 출력합니다.
std::cout << &x << '\n'; // 변수 x의 메모리 주소를 출력합니다.
return 0;
}
저자의 컴퓨터에서 위 프로그램을 실행했을 때 다음과 같이 출력되었습니다.
5
0027FEA0
위 예제에서 우리는 주소 연산자(&)를 사용해 변수 x에 할당된 주소를 가져오고, 그 주소를 화면에 출력했습니다.
메모리 주소는 보통 (0x 접두사 없이) 16진수 값으로 출력됩니다.
만약 메모리를 1바이트 이상 사용하는 객체라면, 주소 연산자는 그 객체가 사용하는 첫 번째 바이트의 메모리 주소를 반환합니다.
팁
&기호는 상황에 따라 의미가 달라지기 때문에 헷갈리기 쉽습니다.
- 타입 이름 바로 뒤에 올 때 (
int& ref): lvalue 레퍼런스- 식에서 변수 앞에 단독으로 쓰일 때 (
&x): 주소 연산자 (주소를 가져옴)- 두 값 사이에 쓰일 때 (
x & y): 비트 AND 연산자
*)단순히 변수의 주소만 알아내는 것은 그 자체로 큰 쓸모가 없습니다.
주소를 가지고 할 수 있는 가장 유용한 일은, 그 주소에 실제로 들어있는 값에 접근하는 것입니다.
역참조 연산자(*)(또는 간접 참조 연산자라고도 부름)는 주어진 메모리 주소에 있는 값을 lvalue로 반환해 줍니다.
#include <iostream>
int main(){
int x{ 5 };
std::cout << x << '\n'; // 변수 x의 값을 출력합니다.
std::cout << &x << '\n'; // 변수 x의 메모리 주소를 출력합니다.
std::cout << *(&x) << '\n'; // 변수 x의 메모리 주소에 있는 값을 출력합니다. (괄호는 필수가 아니지만 코드를 읽기 쉽게 해줍니다)
return 0;
}
저자의 컴퓨터에서는 다음과 같이 출력되었습니다.
5
0027FEA0
5
프로그램의 흐름은 아주 간단합니다. 먼저 변수 x를 선언하고 값을 출력합니다. 그다음 x의 메모리 주소를 출력합니다.
마지막으로, 역참조 연산자를 사용해 x의 메모리 주소로 찾아가 그 안에 있는 값(결국 x의 값인 5)을 가져와 화면에 출력합니다.
핵심 포인트
메모리 주소가 있다면, 역참조 연산자(*)를 사용해 그 주소에 있는 값을 가져올 수 있습니다.
주소 연산자(&)와 역참조 연산자(*)는 완전히 정반대로 작동합니다.&는 객체에서 주소를 얻어내고,*는 주소에서 객체를 얻어냅니다.
팁
역참조 연산자(*)는 곱하기 연산자와 똑같이 생겼지만, 구분하는 법은 쉽습니다. 역참조 연산자는 변수 하나에만 붙고(단항), 곱하기 연산자는 두 개의 값 사이에 들어갑니다(이항).
사실 변수의 메모리 주소를 가져온 다음, 곧바로 다시 역참조해서 값을 가져오는 행위 자체는 크게 유용하지 않습니다. 그냥 변수 이름을 쓰면 되니까요! 하지만 이제 주소 연산자(&)와 역참조 연산자(*)라는 강력한 도구를 얻었으니, 본격적으로 포인터에 대해 이야기할 준비가 되었습니다.
포인터는 일반적인 값 대신 메모리 주소(보통 다른 변수의 주소)를 값으로 저장하는 객체(변수)입니다.
포인터를 사용하면 다른 객체의 주소를 저장해 두었다가 나중에 꺼내 쓸 수 있습니다.
참고
현대의 C++에서는 최근 도입된 '스마트 포인터(smart pointers)'와 구분하기 위해, 지금 우리가 배우는 이 기본적인 포인터를 "원시 포인터(raw pointers)" 또는 "멍청한 포인터(dumb pointers)"라고 부르기도 합니다.
포인터를 지정하는 타입(예: int*)을 포인터 타입이라고 합니다.
레퍼런스 타입을 앰퍼샌드(&)로 선언하듯, 포인터 타입은 별표(*)를 사용해 선언합니다.
int; // 평범한 int 타입
int&; // int 값에 대한 lvalue 레퍼런스
int*; // int 값에 대한 포인터 (정수 값의 주소를 보관함)
포인터 변수를 만드는 방법은 아주 간단합니다. 포인터 타입으로 변수를 정의하기만 하면 됩니다.
int main(){
int x { 5 }; // 일반적인 변수
int& ref { x }; // 정수에 대한 레퍼런스 (x에 연결됨)
int* ptr; // 정수에 대한 포인터
return 0;
}
여기서 쓰인 별표(*)는 포인터를 선언하기 위한 문법의 일부일 뿐, 앞서 배운 역참조 연산자가 아니라는 점에 주의하세요.
모범 사례 (Best practice)
포인터 타입을 선언할 때는 별표(*)를 타입 이름 바로 옆에 붙이는 것이 좋습니다. (int *ptr보다는int* ptr)
경고
한 줄에 여러 개의 변수를 선언하는 것은 권장하지 않지만, 만약 그렇게 한다면 각 변수마다 별표를 붙여줘야 합니다.int* ptr1, ptr2; // 잘못됨: ptr1은 int에 대한 포인터지만, ptr2는 그냥 평범한 int 변수가 됩니다! int* ptr3, * ptr4; // 올바름: ptr3과 ptr4 둘 다 int에 대한 포인터입니다.
일반 변수와 마찬가지로, 포인터도 기본적으로는 초기화되지 않습니다. 초기화되지 않은 포인터는 와일드 포인터(wild pointer)라고 부릅니다. 와일드 포인터는 아무 의미 없는 쓰레기 주소값을 가지고 있기 때문에, 이를 역참조하려고 하면 프로그램이 오작동하는 등 '정의되지 않은 동작(undefined behavior)'이 발생합니다. 따라서 포인터는 항상 안전한 값으로 초기화해야 합니다.
모범 사례 (Best practice)
포인터는 항상 초기화하세요.int main(){ int x{ 5 }; int* ptr; // 초기화되지 않은 포인터 (쓰레기 주소를 가짐) int* ptr2{}; // 널 포인터 (다음 레슨에서 다룰 예정입니다) int* ptr3{ &x }; // 변수 x의 주소로 초기화된 포인터 return 0; }
포인터는 주소를 보관하기 때문에, 포인터를 초기화하거나 값을 넣을 때는 반드시 주소값을 넣어주어야 합니다.
보통 주소 연산자(&)를 사용해 다른 변수의 주소를 가져와서 포인터에 저장합니다.
어떤 객체의 주소를 포인터에 무사히 저장했다면, 이제 역참조 연산자(*)를 사용해 그 주소에 있는 값에 자유롭게 접근할 수 있습니다.
#include <iostream>
int main(){
int x{ 5 };
std::cout << x << '\n'; // 변수 x의 값을 출력합니다.
int* ptr{ &x }; // ptr은 x의 주소를 보관합니다.
std::cout << *ptr << '\n'; // 역참조 연산자를 사용하여 ptr이 보관하고 있는 주소(즉, x의 주소)에 있는 값을 출력합니다.
return 0;
}
결과는 다음과 같습니다.
5
5
이 상황을 개념적으로 이해하자면, ptr이 x의 주소를 가지고 있으므로 우리는 "ptr이 x를 가리킨다(pointing to)"라고 표현합니다.
'포인터'라는 이름도 바로 여기서 유래한 것입니다!
저자의 메모
포인터를 부르는 용어에 대해 짚고 넘어갈게요. "X 포인터"(X는 특정 타입)라는 말은 "X에 대한 포인터"를 짧게 부르는 것입니다. 즉 "정수 포인터"라고 하면 실제로는 "정수(int)에 대한 포인터"를 의미합니다.
레퍼런스의 타입이 연결할 대상의 타입과 일치해야 하듯, 포인터의 타입도 가리킬 대상의 타입과 반드시 일치해야 합니다.
int main(){
int i{ 5 };
double d{ 7.0 };
int* iPtr{ &i }; // 정상: int에 대한 포인터는 int 객체를 가리킬 수 있습니다.
int* iPtr2 { &d }; // 오류: int에 대한 포인터는 double 객체를 가리킬 수 없습니다.
double* dPtr{ &d }; // 정상: double에 대한 포인터는 double 객체를 가리킬 수 있습니다.
double* dPtr2{ &i }; // 오류: double에 대한 포인터는 int 객체를 가리킬 수 없습니다.
return 0;
}
또한, 포인터에 숫자(리터럴 값)를 직접 넣어 초기화하는 것은 허용되지 않습니다. (다음 레슨에서 배울 한 가지 예외는 제외하고요)
int* ptr{ 5 }; // 오류
int* ptr{ 0x0012FF7C }; // 오류: 0x0012FF7C는 그냥 정수 숫자로 취급됩니다.
포인터에서 대입 연산자(=)를 사용하는 방법은 두 가지가 있습니다.
먼저, 포인터가 다른 객체를 가리키도록 바꾸는 경우를 살펴봅시다.
#include <iostream>
int main(){
int x{ 5 };
int* ptr{ &x }; // ptr이 x를 가리키도록 초기화합니다.
std::cout << *ptr << '\n'; // 가리키고 있는 주소(x의 주소)에 있는 값을 출력합니다.
int y{ 6 };
ptr = &y; // ptr이 y를 가리키도록 변경합니다.
std::cout << *ptr << '\n'; // 가리키고 있는 주소(y의 주소)에 있는 값을 출력합니다.
return 0;
}
결과는 다음과 같습니다.
5
6
위 예제에서는 먼저 ptr을 x의 주소로 초기화한 후 역참조하여 5를 출력했습니다.
그런 다음 ptr = &y;를 통해 ptr이 보관하는 주소를 y의 주소로 바꿨습니다.
다시 역참조해보면 이제 y의 값인 6이 출력됩니다.
이번에는 포인터를 이용해 가리키는 대상의 값을 직접 바꾸는 방법을 확인해 보겠습니다.
#include <iostream>
int main(){
int x{ 5 };
int* ptr{ &x }; // ptr을 변수 x의 주소로 초기화합니다.
std::cout << x << '\n'; // x의 값을 출력합니다.
std::cout << *ptr << '\n'; // ptr이 보관하고 있는 주소(x의 주소)에 있는 값을 출력합니다.
*ptr = 6; // ptr이 보관하고 있는 주소에 있는 객체(x)에 값 6을 대입합니다. (여기서 ptr이 역참조되었음에 주목하세요)
std::cout << x << '\n';
std::cout << *ptr << '\n'; // ptr이 보관하고 있는 주소(x의 주소)에 있는 값을 출력합니다.
return 0;
}
결과는 다음과 같습니다.
5
5
6
6
*ptr은 lvalue를 반환하기 때문에, 대입 연산자 왼쪽(=)에 두고 새로운 값을 넣을 수 있습니다.
위 코드에서 *ptr = 6;을 실행하면, 실제로 ptr이 가리키고 있던 변수 x의 값이 6으로 업데이트됩니다.
핵심 포인트
- 역참조 없이
ptr만 쓰면, 포인터가 가지고 있는 메모리 주소 자체에 접근합니다.
(ptr = &y;처럼 쓰면 포인터가 가리키는 '방향'을 바꿉니다)- 역참조 기호를 붙여
*ptr로 쓰면, 포인터가 가리키고 있는 진짜 객체에 접근합니다.
(*ptr = 6;처럼 쓰면 가리키고 있는 변수의 '내용물'을 바꿉니다)
포인터와 lvalue 레퍼런스의 역할은 꽤 비슷합니다. 다음 프로그램을 확인해 보세요.
#include <iostream>
int main(){
int x{ 5 };
int& ref { x }; // x에 대한 레퍼런스를 가져옵니다.
int* ptr { &x }; // x에 대한 포인터를 가져옵니다.
std::cout << x;
std::cout << ref; // 레퍼런스를 사용하여 x의 값을 출력합니다 (5)
std::cout << *ptr << '\n'; // 포인터를 사용하여 x의 값을 출력합니다 (5)
ref = 6; // 레퍼런스를 사용하여 x의 값을 변경합니다.
std::cout << x;
std::cout << ref; // 레퍼런스를 사용하여 x의 값을 출력합니다 (6)
std::cout << *ptr << '\n'; // 포인터를 사용하여 x의 값을 출력합니다 (6)
*ptr = 7; // 포인터를 사용하여 x의 값을 변경합니다.
std::cout << x;
std::cout << ref; // 레퍼런스를 사용하여 x의 값을 출력합니다 (7)
std::cout << *ptr << '\n'; // 포인터를 사용하여 x의 값을 출력합니다 (7)
return 0;
}
이 프로그램의 결과는 다음과 같습니다.
555
666
777
결국 포인터와 레퍼런스 모두 다른 객체에 간접적으로 접근할 수 있는 방법을 제공합니다.
가장 큰 차이점은 포인터는 우리가 직접 &로 주소를 가져오고 *로 역참조를 해줘야 하지만, 레퍼런스는 이런 과정이 뒷단에서 자동으로 처리된다는 것입니다.
포인터와 레퍼런스의 다른 차이점들은 다음과 같습니다.
주소 연산자(&)는 메모리 주소를 단순한 숫자로 반환하지 않고, 피연산자를 가리키는 포인터 타입을 반환한다는 점을 기억해 두면 좋습니다. (C++는 주소를 직접적인 리터럴 값으로 지원하지 않기 때문입니다.)
즉 int x라는 변수가 있을 때, &x는 x의 주소를 담고 있는 int* (정수형 포인터)를 반환합니다.
#include <iostream>
#include <typeinfo>
int main(){
int x{ 4 };
std::cout << typeid(x).name() << '\n'; // x의 타입을 출력합니다.
std::cout << typeid(&x).name() << '\n'; // &x의 타입을 출력합니다.
return 0;
}
Visual Studio에서는 다음과 같이 출력됩니다.
int
int *
(컴파일러마다 표기가 조금 다를 수 있지만 의미는 같습니다.)
포인터가 차지하는 메모리 크기는 프로그램을 실행하는 컴퓨터의 시스템 환경(아키텍처)에 따라 달라집니다.
32비트 환경에서는 메모리 주소도 32비트이므로, 포인터의 크기 역시 32비트(4바이트)가 됩니다. 64비트 환경에서는 64비트(8바이트)가 됩니다.
중요한 점은, 포인터가 가리키는 대상이 얼마나 큰지와 상관없이 포인터 자체의 크기는 항상 일정하다는 것입니다.
#include <iostream>
int main() // 32비트 애플리케이션이라고 가정합시다.
{
char* chPtr{}; // char는 1바이트입니다.
int* iPtr{}; // int는 보통 4바이트입니다.
long double* ldPtr{}; // long double은 보통 8바이트 또는 12바이트입니다.
std::cout << sizeof(chPtr) << '\n'; // 4를 출력합니다.
std::cout << sizeof(iPtr) << '\n'; // 4를 출력합니다.
std::cout << sizeof(ldPtr) << '\n'; // 4를 출력합니다.
return 0;
}
포인터는 결국 '메모리 주소'일 뿐이고, 주소를 저장하는 데 필요한 공간의 크기는 어떤 타입이든 동일하기 때문입니다.
'댕글링(Dangling, 공중에 붕 뜬)' 레퍼런스와 마찬가지로, 댕글링 포인터는 더 이상 유효하지 않은(예: 이미 파괴되어 사라진) 객체의 주소를 들고 있는 포인터를 뜻합니다.
유효하지 않은 객체에 접근하려는 것이기 때문에, 댕글링 포인터를 역참조하면 프로그램이 멈추거나 오작동하는 등 정의되지 않은 동작이 발생합니다.
핵심 포인트
유효하지 않은 포인터를 역참조하면 정의되지 않은 심각한 동작이 발생합니다.
그 주소를 다른 데 복사하는 등 단순한 사용조차도 컴파일러에 따라 결과가 다르게 나타날 수 있으니 매우 주의해야 합니다.
댕글링 포인터가 만들어지는 대표적인 예시는 다음과 같습니다.
#include <iostream>
int main(){
int x{ 5 };
int* ptr{ &x };
std::cout << *ptr << '\n'; // 정상 작동합니다.
{
int y{ 6 };
ptr = &y;
std::cout << *ptr << '\n'; // 정상 작동합니다.
} // 블록이 끝나면서 y가 범위를 벗어나 파괴됩니다. 이제 ptr은 댕글링 포인터가 됩니다.
std::cout << *ptr << '\n'; // 댕글링 포인터를 역참조하여 정의되지 않은 동작이 발생합니다.
return 0;
}
위 프로그램은 아마도 이렇게 출력될 것입니다.
5
6
6
하지만 마지막 6이 제대로 출력되지 않을 수도 있습니다. 왜냐하면 안쪽 중괄호 블록이 끝날 때 변수 y가 메모리에서 파괴되어 사라졌고, ptr은 사라진 y의 자리를 여전히 가리키고 있는 '댕글링 포인터'가 되어버렸기 때문입니다.
포인터는 메모리 주소를 저장하는 변수입니다. 역참조 연산자(*)를 사용하면 포인터가 가리키고 있는 주소로 찾아가 값을 가져오거나 바꿀 수 있습니다. 초기화되지 않았거나(와일드), 대상이 사라졌거나(댕글링), 비어있는(널) 포인터를 역참조하면 심각한 오류가 발생해 프로그램이 강제 종료될 가능성이 높습니다.
포인터는 레퍼런스보다 훨씬 유연하게 활용할 수 있지만, 그만큼 위험성도 큽니다. 앞으로의 레슨에서 이를 더 깊이 파헤쳐 보겠습니다.
지난 레슨(12.7 -- 포인터 소개)에서는 다른 객체의 주소를 저장하는 '포인터(pointer)'의 기본을 다뤘습니다.
포인터가 가지고 있는 주소는 역참조 연산자(*)를 사용해 그 주소에 있는 실제 객체를 가져오는 데 쓸 수 있습니다.
#include <iostream>
int main()
{
int x{ 5 };
std::cout << x << '\n'; // 변수 x의 값을 출력합니다
int* ptr{ &x }; // ptr은 x의 주소를 저장합니다
std::cout << *ptr << '\n'; // 역참조 연산자를 사용하여 ptr이 저장하고 있는 주소(즉, x의 주소)에 있는 객체의 값을 출력합니다
return 0;
}
위 예제를 실행하면 다음과 같이 출력됩니다.
5
5
이전 레슨에서 포인터가 반드시 무언가를 가리켜야만 하는 것은 아니라고 언급한 적이 있습니다.
이번 레슨에서는 이렇게 '아무것도 가리키지 않는 포인터'와 그로 인해 발생하는 여러 가지 상황들을 자세히 알아보겠습니다.
포인터는 메모리 주소 외에도 '널(null) 값'이라는 것을 가질 수 있습니다.
널 값(보통 줄여서 '널'이라고 부름)은 '아무런 값도 없음'을 뜻하는 특별한 값입니다.
포인터가 널 값을 가지고 있다는 것은, 그 포인터가 현재 아무것도 가리키고 있지 않다는 뜻입니다.
이런 포인터를 널 포인터(null pointer)라고 부릅니다.
널 포인터를 만드는 가장 쉬운 방법은 값 초기화(value initialization)를 사용하는 것입니다.
int main()
{
int* ptr {}; // ptr은 이제 널 포인터이며, 어떤 주소도 가지고 있지 않습니다
return 0;
}
모범 사례 (Best practice)
유효한 객체의 주소로 포인터를 초기화하지 않을 거라면, 우선 값 초기화를 통해 널 포인터로 만들어 두는 것이 좋습니다.
포인터는 나중에 대입(assignment)을 통해 가리키는 대상을 언제든 바꿀 수 있습니다.
따라서 처음에는 널(null)로 설정된 포인터라도 나중에 진짜 유효한 객체를 가리키도록 변경할 수 있습니다.
#include <iostream>
int main()
{
int* ptr {}; // ptr은 널 포인터이며, 아직 어떤 주소도 가지고 있지 않습니다
int x { 5 };
ptr = &x; // ptr은 이제 객체 x를 가리킵니다 (더 이상 널 포인터가 아닙니다)
std::cout << *ptr << '\n'; // 역참조된 ptr을 통해 x의 값을 출력합니다
return 0;
}
true나 false라는 키워드가 참/거짓을 나타내는 것처럼, nullptr이라는 키워드는 널 포인터 리터럴(값 자체)을 나타냅니다. nullptr을 사용하면 포인터를 명시적으로 널 포인터로 초기화하거나 널 값을 대입할 수 있습니다.
int main()
{
int* ptr { nullptr }; // nullptr을 사용하여 포인터를 널 포인터로 초기화할 수 있습니다
int value { 5 };
int* ptr2 { &value }; // ptr2는 유효한 포인터입니다
ptr2 = nullptr; // nullptr을 대입하여 포인터를 다시 널 포인터로 만들 수 있습니다
someFunction(nullptr); // 포인터를 매개변수로 받는 함수에 곧바로 nullptr을 전달할 수도 있습니다
return 0;
}
위의 예제처럼 대입 연산자를 통해 ptr2의 값을 nullptr로 설정하면 ptr2는 널 포인터가 됩니다.
모범 사례 (Best practice)
포인터를 초기화하거나, 대입하거나, 함수에 널 포인터를 전달할 때 항상nullptr키워드를 사용하세요.
허공을 가리키는 댕글링 포인터(dangling pointer)를 역참조하면 알 수 없는 문제가 생기는 것처럼, 널 포인터를 역참조해도 정의되지 않은 동작(undefined behavior)이 발생합니다. 대부분의 경우 프로그램이 그 즉시 비정상적으로 멈추거나 꺼져버립니다.
다음 프로그램은 이를 잘 보여줍니다.
코드를 실행해 보면 아마 프로그램이 비정상 종료될 것입니다. (직접 실행해 보셔도 컴퓨터가 고장 나지는 않으니 안심하세요!)
#include <iostream>
int main()
{
int* ptr {}; // 널 포인터를 생성합니다
std::cout << *ptr << '\n'; // 널 포인터를 역참조합니다 (위험!)
return 0;
}
원리를 생각해 보면 당연한 일입니다. 포인터를 역참조한다는 것은 "이 포인터가 가리키는 주소로 찾아가서 그곳에 있는 값을 가져와라"라는 뜻입니다. 그런데 널 포인터는 '아무것도 가리키지 않음'을 뜻하는데, 도대체 어디로 가서 무슨 값을 가져올 수 있을까요?
실수로 널 포인터나 댕글링 포인터를 역참조하는 것은 C++ 프로그래머들이 가장 자주 저지르는 실수이자, 실제 현업에서 프로그램이 뻗어버리는(crash) 가장 흔한 원인이기도 합니다.
경고 (Warning)
포인터를 다룰 때는 코드가 널 포인터나 댕글링 포인터를 역참조하지 않도록 각별히 주의해야 합니다.
그렇지 않으면 프로그램이 멈추는 등 정의되지 않은 동작이 발생합니다.
if 같은 조건문을 사용해 값이 true인지 false인지 확인하는 것처럼, 조건문을 사용해 포인터가 nullptr인지 아닌지 확인할 수도 있습니다.
#include <iostream>
int main()
{
int x { 5 };
int* ptr { &x };
if (ptr == nullptr) // nullptr과 같은지 명시적으로 검사
std::cout << "ptr is null\n";
else
std::cout << "ptr is non-null\n";
int* nullPtr {};
std::cout << "nullPtr is " << (nullPtr==nullptr ? "null\n" : "non-null\n"); // 명시적으로 검사
return 0;
}
위 프로그램의 출력 결과는 다음과 같습니다.
ptr is non-null
nullPtr is null
이전 레슨(4.9 불리언 값)에서 숫자 0은 불리언 값 false로 변환되고, 다른 숫자는 true로 변환된다고 배웠습니다.
포인터 역시 불리언 값으로 자연스럽게(암시적으로) 변환됩니다.
널 포인터는 false로, 널이 아닌 일반 포인터는 true로 변환됩니다.
덕분에 굳이 == nullptr이라고 길게 쓰지 않아도, 다음과 같이 간단하게 널 포인터 여부를 확인할 수 있습니다.
#include <iostream>
int main()
{
int x { 5 };
int* ptr { &x };
// 포인터는 널일 경우 false로, 널이 아닐 경우 true로 자동 변환됩니다
if (ptr) // 불리언으로의 암시적 변환
std::cout << "ptr is non-null\n";
else
std::cout << "ptr is null\n";
int* nullPtr {};
std::cout << "nullPtr is " << (nullPtr ? "non-null\n" : "null\n"); // 불리언으로의 암시적 변환
return 0;
}
경고 (Warning)
이러한 조건문 검사는 포인터가 널인지, 널이 아닌지만 구분해 줍니다. 널이 아닌 포인터가 '안전하고 유효한 객체'를 가리키고 있는지, 아니면 '이미 사라진 허공(댕글링 상태)'을 가리키고 있는지까지 알아낼 수 있는 마법 같은 방법은 없습니다.
앞서 널 포인터나 댕글링 포인터를 역참조하면 심각한 문제가 생긴다고 말씀드렸습니다.
따라서 우리 코드가 절대 이런 짓을 하지 않도록 막아야 합니다.
널 포인터를 역참조하는 문제는 조건문을 써서 포인터가 널이 아닐 때만 접근하게 하면 쉽게 막을 수 있습니다.
// ptr이 널 포인터일 수도 있고 아닐 수도 있는 어떤 포인터라고 가정해 봅시다.
if (ptr) // ptr이 널 포인터가 아니라면
std::cout << *ptr << '\n'; // 역참조해도 안전합니다
else
// ptr을 역참조하지 않는 다른 안전한 작업을 수행합니다 (에러 메시지를 띄우거나, 아무것도 하지 않음 등)
그렇다면 댕글링 포인터는 어떻게 해야 할까요? 앞서 말했듯 포인터가 댕글링 상태인지 알아낼 방법은 없으므로, 애초에 프로그램 안에 댕글링 포인터가 돌아다니지 않도록 막는 것이 최선입니다. 이를 위해 유효한 객체를 가리키고 있지 않은 포인터는 반드시 nullptr로 덮어씌워 주어야 합니다.
그렇게 규칙을 지키면, 포인터를 역참조하기 전에 그저 '널인지 아닌지'만 확인하면 됩니다. 널이 아니라면 댕글링 상태가 아닐 것이라고 안심하고 코드를 짤 수 있기 때문입니다.
모범 사례 (Best practice)
모든 포인터는 유효한 객체의 주소를 가지고 있거나, 아니면nullptr상태여야만 합니다.
그렇게 관리하면 포인터가 널인지 여부만 체크해도 코드가 안전하게 작동합니다.
하지만 안타깝게도 댕글링 포인터를 피하는 것이 항상 말처럼 쉽지만은 않습니다. 객체가 메모리에서 삭제되어(파괴되어) 사라지면, 그 객체를 가리키던 모든 포인터는 허공을 맴도는 댕글링 상태가 됩니다. 이런 포인터들은 자동으로 nullptr로 바뀌지 않습니다! 방금 사라진 객체를 가리키고 있던 포인터들을 찾아내어 모두 nullptr로 초기화해 주는 것은 온전히 프로그래머의 몫입니다.
경고 (Warning)
객체가 파괴되면 그 객체를 가리키던 모든 포인터는 댕글링 상태가 됩니다 (자동으로nullptr이 되지 않습니다). 이런 상황을 잘 파악하고 직접 포인터를nullptr로 변경해 주어야 합니다.
오래전에 작성된 코드를 보면 nullptr 대신 다른 두 가지 값이 쓰인 것을 볼 수 있습니다.
첫 번째는 숫자 0입니다. 포인터 문맥에서 숫자 0은 특별히 널(null) 값을 의미하도록 약속되어 있습니다.
C++에서 포인터에 그냥 일반 숫자를 대입할 수 있는 유일한 예외 상황이 바로 이 0을 넣을 때뿐입니다.
int main()
{
float* ptr { 0 }; // ptr은 이제 널 포인터입니다 (예시일 뿐이니, 요즘 코드엔 이렇게 쓰지 마세요)
float* ptr2; // ptr2는 초기화되지 않았습니다
ptr2 = 0; // ptr2는 이제 널 포인터가 됩니다 (마찬가지로 권장하지 않습니다)
return 0;
}
참고로...
현대의 컴퓨터 구조(아키텍처)에서는 널 포인터를 나타내기 위해 주로 메모리 주소0번지를 사용합니다.
하지만 C++ 표준이 이를 강제하는 것은 아니라서, 일부 독특한 컴퓨터 환경에서는 다른 주소 값을 널로 쓰기도 합니다.
만약 포인터 자리에 숫자0을 넣으면, 컴퓨터가 알아서 '자신의 환경에 맞는 널 포인터 주소'로 똑똑하게 번역해 줍니다.
두 번째로 자주 보이는 것은 <cstddef> 헤더에 정의되어 있는 NULL이라는 매크로입니다. 이것은 C 언어 시절부터 사용되던 유산으로, 과거에는 널 포인터를 표현할 때 가장 흔하게 쓰였습니다.
하지만 현대 C++ 프로그래밍에서는 0과 NULL 둘 다 사용하지 않는 것이 좋습니다 (대신 항상 nullptr을 사용하세요).
그 이유에 대해서는 나중에 레슨 12.11 (주소로 전달하기 파트 2)에서 더 자세히 다루겠습니다.
포인터와 참조(reference) 모두 '다른 객체에 간접적으로 접근'할 수 있게 해주는 기능입니다.
포인터는 가리키는 대상을 마음대로 바꿀 수 있고, 아무것도 가리키지 않는 널(null) 상태가 될 수도 있다는 추가적인 능력이 있습니다.
하지만 그 강력한 능력 때문에 훨씬 더 위험합니다. 널 포인터를 실수로 사용할 위험이 항상 존재하며, 대상을 자유롭게 바꿀 수 있다 보니 실수로 댕글링 포인터를 만들기도 쉽습니다.
int main()
{
int* ptr { };
{
int x{ 5 };
ptr = &x; // 포인터에 곧 사라질 운명인 객체를 연결합니다 (참조로는 이런 실수를 하기가 힘듭니다)
} // 중괄호가 끝나면서 x는 파괴되었고, ptr은 이제 허공을 가리키는 댕글링 상태가 되었습니다
if (ptr) // ptr이 nullptr은 아니기 때문에 이 조건문은 true로 통과해버립니다
std::cout << *ptr; // 이미 사라진 대상을 역참조하므로 정의되지 않은 동작이 발생합니다!
return 0;
}
반면, 참조(reference)는 애초에 널(null) 상태가 되는 것 자체가 불가능하므로 널 참조에 대한 걱정을 할 필요가 없습니다.
또한 참조는 만들어질 때 무조건 유효한 객체와 연결되어야 하고, 한 번 연결되면 다른 객체로 중간에 바꿀 수 없기 때문에 댕글링 참조를 만드는 실수도 훨씬 덜 일어납니다.
결론적으로 참조가 포인터보다 훨씬 안전하므로, 꼭 포인터의 특별한 능력이 필요한 상황이 아니라면 포인터보다는 참조를 우선적으로 사용하는 것이 좋습니다.
모범 사례 (Best practice)
포인터가 제공하는 추가적인 기능이 반드시 필요한 경우가 아니라면, 포인터 대신 참조(reference)를 우선적으로 사용하세요.
다음 코드를 살펴봅시다.
int main(){
int x { 5 };
int* ptr { &x }; // ptr은 일반적인(const가 아닌) 포인터입니다.
int y { 6 };
ptr = &y; // 다른 값의 주소를 가리키도록 변경할 수 있습니다.
*ptr = 7; // 포인터가 가리키고 있는 주소의 값을 변경할 수 있습니다.
return 0;
}
일반적인(const가 아닌) 포인터를 사용하면, 포인터가 가리키는 대상(새로운 주소 할당)과 가리키고 있는 주소의 값(포인터를 통해 새 값 할당)을 모두 변경할 수 있습니다.
하지만, 만약 우리가 가리키려는 값이 const(상수)라면 어떻게 될까요?
int main(){
const int x { 5 }; // 이제 x는 const(상수)입니다.
int* ptr { &x }; // 컴파일 에러: const int* 에서 int* 로 변환할 수 없습니다.
return 0;
}
위 코드는 에러가 발생하여 실행되지 않습니다. 일반 포인터가 const 변수를 가리키게 할 수는 없기 때문입니다.
이는 아주 당연한 이치입니다. const 변수는 값을 변경할 수 없도록 잠가둔 변수입니다. 만약 일반 포인터가 const 값을 가리키도록 허락한다면, 프로그래머가 포인터를 이용해 그 값을 마음대로 바꿔버릴 수 있게 되고, 이는 변수를 const로 만든 의미를 훼손하게 됩니다.
상수 값을 가리키는 포인터(간단히 '상수 포인터'라고도 함)는 말 그대로 값이 변하지 않는 상수(const)를 가리키는 일반 포인터입니다.
이 포인터를 선언하려면, 포인터의 데이터 타입 앞에 const 키워드를 붙여주면 됩니다.
int main(){
const int x{ 5 };
const int* ptr { &x }; // 정상: ptr은 "const int"를 가리키고 있습니다.
*ptr = 6; // 허용되지 않음: const 값은 변경할 수 없습니다.
return 0;
}
위 예제에서 ptr은 const int를 가리킵니다. 가리키는 대상이 상숫값이므로, 포인터를 통해 그 값을 변경할 수는 없습니다.
하지만 포인터 자체는 const가 아니기 때문에(단지 const인 값을 가리킬 뿐입니다), 포인터에 새로운 주소를 할당하여 가리키는 대상을 다른 곳으로 바꿀 수는 있습니다.
int main(){
const int x{ 5 };
const int* ptr { &x }; // ptr은 const int x를 가리킵니다.
const int y{ 6 };
ptr = &y; // 정상: 이제 ptr은 const int y를 가리킵니다.
return 0;
}
참조(reference)와 마찬가지로, 이 포인터는 const가 아닌 일반 변수도 가리킬 수 있습니다.
단지 이 포인터를 통해 접근할 때는, 원래 변수가 const인지 아닌지와 상관없이 무조건 상수로 취급하여 값을 바꿀 수 없게 만듭니다.
int main(){
int x{ 5 }; // const가 아님
const int* ptr { &x }; // ptr은 "const int"를 가리킵니다.
*ptr = 6; // 허용되지 않음: ptr은 "const int"를 가리키므로 ptr을 통해서는 값을 바꿀 수 없습니다.
x = 6; // 허용됨: 일반 변수 이름인 x를 통해 접근할 때는 여전히 값을 바꿀 수 있습니다.
return 0;
}
포인터 자체를 상수로 만들 수도 있습니다. const 포인터는 한 번 초기화되면 자기가 가리키는 주소값을 영원히 바꿀 수 없는 포인터를 말합니다.
const 포인터를 선언하려면 포인터를 뜻하는 별표(*) 기호 뒤에 const 키워드를 적어줍니다:
int main(){
int x{ 5 };
int* const ptr { &x }; // 별표 뒤의 const는 이 포인터가 'const 포인터'임을 의미합니다.
return 0;
}
위 코드에서 ptr은 일반 int 값을 가리키는 const 포인터입니다.
일반적인 const 변수처럼, const 포인터는 만들어짐과 동시에 반드시 주소값을 넣어 초기화해야 하며, 나중에 다른 주소로 바꿀 수 없습니다:
int main(){
int x{ 5 };
int y{ 6 };
int* const ptr { &x }; // 정상: const 포인터가 x의 주소로 초기화되었습니다.
ptr = &y; // 에러: 한 번 초기화되면 const 포인터의 주소는 바꿀 수 없습니다.
return 0;
}
하지만, 포인터가 가리키고 있는 값 자체는 일반 변수(non-const)이므로, 포인터를 이용해서 그 값을 바꾸는 것은 가능합니다.
int main(){
int x{ 5 };
int* const ptr { &x }; // ptr은 이제 평생 x만 가리킵니다.
*ptr = 6; // 정상: 가리키고 있는 값은 const가 아니므로 바꿀 수 있습니다.
return 0;
}
마지막으로, 데이터 타입 앞과 별표(*) 뒤에 모두 const 키워드를 사용하여 상수 값을 가리키는 const 포인터를 선언할 수도 있습니다:
int main(){
int value { 5 };
const int* const ptr { &value }; // 상수 값을 가리키는 const 포인터
return 0;
}
이 포인터는 자신이 가진 주소값도 바꿀 수 없고, 포인터를 통해 가리키는 값도 바꿀 수 없습니다. 오로지 가리키는 값을 읽어오는 것만 가능합니다.
복잡해 보이지만, 다음 4가지 규칙만 기억하시면 됩니다. 아주 논리적입니다.
int* ptr): 다른 주소를 할당하여 가리키는 대상을 변경할 수 있습니다.int* const ptr): 항상 같은 주소만 가리키며, 가리키는 대상을 변경할 수 없습니다.int* ptr): 포인터를 통해 가리키는 값을 변경할 수 있습니다. 단, const 값은 가리킬 수 없습니다.const int* ptr): 포인터를 통해 접근할 때 그 값을 상수로 취급하므로, 값을 변경할 수 없습니다. 이 포인터는 const 변수나 일반 변수 모두 가리킬 수 있습니다. (단, 주소가 없는 임시 값은 가리킬 수 없습니다).작성법을 외우는 것이 조금 헷갈릴 수 있는데, 이렇게 기억해 보세요:
*) 좌측의 const (예: const int* ptr): 가리키는 데이터의 '타입'과 관련이 있습니다. 즉, 값이 상수이므로 포인터를 통해 값을 수정할 수 없습니다.*) 우측의 const (예: int* const ptr): 포인터 '자체'와 관련이 있습니다. 즉, 포인터가 다른 곳을 가리키도록 주소를 수정할 수 없습니다.int main(){
int v{ 5 };
int* ptr0 { &v }; // "int"를 가리키며 자신은 const가 아닙니다. 값과 주소 모두 수정할 수 있습니다.
const int* ptr1 { &v }; // "const int"를 가리키며 자신은 const가 아닙니다. 주소만 수정할 수 있습니다.
int* const ptr2 { &v }; // "int"를 가리키며 자신은 const입니다. 값만 수정할 수 있습니다.
const int* const ptr3 { &v }; // "const int"를 가리키며 자신은 const입니다. 값도 주소도 수정할 수 없습니다.
// const가 * 의 왼쪽에 있으면, const는 '값'에 적용됩니다.
// const가 * 의 오른쪽에 있으면, const는 '포인터'에 적용됩니다.
return 0;
}
이전 강의에서 우리는 함수에 인수를 전달하는 두 가지 방법,
즉 값으로 전달하기(Pass by value)와 참조로 전달하기(Pass by reference)에 대해 배웠습니다.
다음은 std::string 객체를 값과 참조로 각각 전달하는 방법을 보여주는 간단한 예제 프로그램입니다.
#include <iostream>
#include <string>
void printByValue(std::string val) // 함수 매개변수는 str의 복사본입니다
{
std::cout << val << '\n'; // 복사본을 통해 값을 출력합니다
}
void printByReference(const std::string& ref) // 함수 매개변수는 str에 연결(바인딩)된 참조입니다
{
std::cout << ref << '\n'; // 참조를 통해 값을 출력합니다
}
int main()
{
std::string str{ "Hello, world!" };
printByValue(str); // 값으로 str을 전달하며, str의 복사본을 만듭니다
printByReference(str); // 참조로 str을 전달하며, 복사본을 만들지 않습니다
return 0;
}
인수 str을 값으로 전달하면, 함수 매개변수인 val은 원래 인수의 '복사본'을 받게 됩니다.
매개변수가 복사본이기 때문에, 함수 안에서 val을 변경하더라도 원본 str에는 아무런 영향을 주지 않습니다.
반면, str을 참조로 전달하면 참조 매개변수인 ref가 실제 인수에 직접 연결(바인딩)됩니다. 따라서 복사본을 만들지 않습니다.
위 예제에서는 참조 매개변수가 const(상수)이므로 ref를 통해 값을 변경할 수는 없습니다.
하지만 만약 ref가 const가 아니었다면, 함수 안에서 ref를 변경할 때 원본 str도 함께 변경되었을 것입니다.
두 경우 모두, 호출하는 쪽에서 함수에 인수로 전달할 실제 객체(str)를 제공한다는 점은 같습니다.
C++에는 함수에 값을 전달하는 세 번째 방법이 있는데, 이를 주소로 전달하기라고 부릅니다.
이 방법에서는 객체 자체를 전달하는 대신, 호출하는 쪽에서 (포인터를 통해) 객체의 주소를 전달합니다.
객체의 주소를 담고 있는 이 포인터는 호출된 함수의 포인터 매개변수로 복사됩니다.
그런 다음 함수는 해당 포인터를 역참조(dereference, 포인터가 가리키는 실제 값에 접근)하여 전달된 객체를 사용할 수 있습니다.
위 프로그램에 '주소로 전달하기' 방식을 추가한 버전은 다음과 같습니다.
#include <iostream>
#include <string>
void printByValue(std::string val) // 함수 매개변수는 str의 복사본입니다
{
std::cout << val << '\n'; // 복사본을 통해 값을 출력합니다
}
void printByReference(const std::string& ref) // 함수 매개변수는 str에 연결(바인딩)된 참조입니다
{
std::cout << ref << '\n'; // 참조를 통해 값을 출력합니다
}
void printByAddress(const std::string* ptr) // 함수 매개변수는 str의 주소를 담고 있는 포인터입니다
{
std::cout << *ptr << '\n'; // 역참조된 포인터를 통해 값을 출력합니다
}
int main()
{
std::string str{ "Hello, world!" };
printByValue(str); // 값으로 str을 전달하며, str의 복사본을 만듭니다
printByReference(str); // 참조로 str을 전달하며, 복사본을 만들지 않습니다
printByAddress(&str); // 주소로 str을 전달하며, 복사본을 만들지 않습니다
return 0;
}
이 세 가지 버전이 얼마나 비슷한지 눈치채셨나요? 이제 '주소로 전달하기' 버전을 좀 더 자세히 살펴보겠습니다.
첫째, printByAddress() 함수가 주소로 값을 받게 하기 위해, 함수 매개변수를 ptr이라는 이름의 포인터로 만들었습니다.
이 함수는 값을 읽기만 할 것이므로 ptr은 상수 값을 가리키는 포인터(const 포인터)로 선언되었습니다.
함수 내부에서는 ptr 매개변수를 역참조(*ptr)하여 포인터가 가리키는 객체의 값에 접근합니다.
둘째, 함수를 호출할 때 그냥 str 객체를 넘겨줄 수는 없습니다.
str의 주소를 넘겨주어야 합니다. 가장 쉬운 방법은 주소 연산자(&)를 사용하여 str의 주소를 담은 포인터를 얻는 것입니다.
printByAddress(&str); // 주소 연산자(&)를 사용하여 str의 주소를 담은 포인터를 얻습니다
이 코드가 실행되면 &str은 str의 주소를 담은 포인터를 생성합니다.
그런 다음 이 주소는 함수 호출 과정에서 매개변수 ptr에 복사됩니다.
이제 ptr이 str의 주소를 가지고 있으므로, 함수가 ptr을 역참조하면 str의 값을 얻게 되고 그 값을 화면에 출력하게 됩니다.
이게 전부입니다!
위 예제에서는 주소 연산자(&)를 사용해 str의 주소를 직접 구했지만, 만약 이미 str의 주소를 담고 있는 포인터 변수가 있다면 그것을 대신 전달해도 됩니다.
int main()
{
std::string str{ "Hello, world!" };
printByValue(str); // 값으로 str을 전달하며, str의 복사본을 만듭니다
printByReference(str); // 참조로 str을 전달하며, 복사본을 만들지 않습니다
printByAddress(&str); // 주소로 str을 전달하며, 복사본을 만들지 않습니다
std::string* ptr { &str }; // str의 주소를 담는 포인터 변수를 정의합니다
printByAddress(ptr); // 주소로 str을 전달하며, 복사본을 만들지 않습니다
return 0;
}
& 연산자를 사용하여 변수의 주소를 인수로 전달할 때, 우리는 "변수가 주소로 전달되었다"고 말합니다.다음 코드를 살펴봅시다.
std::string str{ "Hello, world!" };
printByAddress(&str); // 주소 연산자(&)를 사용하여 str의 주소를 담은 포인터를 얻습니다
이전 강의에서 언급했듯, std::string을 복사하는 것은 비용이 꽤 많이 드는 작업이라 피하는 것이 좋습니다. std::string을 주소로 전달하면, 실제 std::string 객체를 복사하는 것이 아닙니다. 단지 객체의 주소가 담긴 포인터만 호출자에서 함수로 복사할 뿐입니다. 주소는 보통 4바이트나 8바이트의 작은 크기이므로 포인터를 복사하는 작업은 항상 매우 빠릅니다.
따라서 참조로 전달하는 것과 마찬가지로, 주소로 전달하는 방식도 빠르며 인수로 전달된 객체의 복사본을 만들지 않습니다.
객체를 주소로 전달하면 함수는 전달된 객체의 주소를 받게 되고, 포인터를 역참조하여 객체에 접근할 수 있습니다.
이것은 객체의 복사본이 아닌 실제 원본 인수의 주소이기 때문에, 함수 매개변수가 const가 아닌 포인터라면 함수 안에서 포인터를 통해 원본 값을 수정할 수 있습니다.
#include <iostream>
void changeValue(int* ptr) // 참고: 이 예제에서 ptr은 const가 아닌 값을 가리키는 포인터입니다
{
*ptr = 6; // 값을 6으로 변경합니다
}
int main()
{
int x{ 5 };
std::cout << "x = " << x << '\n';
changeValue(&x); // 함수에 x의 주소를 전달하고 있습니다
std::cout << "x = " << x << '\n';
return 0;
}
이 코드는 다음과 같이 출력합니다.
x = 5
x = 6
보시다시피 인수의 값이 수정되었고, 이 변경 사항은 changeValue() 함수가 끝난 후에도 계속 유지됩니다.
만약 함수가 전달받은 객체를 수정하지 않아야 한다면, 함수 매개변수를 const 값을 가리키는 포인터(pointer-to-const)로 만들어야 합니다.
void changeValue(const int* ptr) // 참고: 이제 ptr은 const 값을 가리키는 포인터입니다
{
*ptr = 6; // 오류: 상수(const) 값을 변경할 수 없습니다
}
일반 매개변수에 const를 잘 붙이지 않는 이유와 마찬가지로, 포인터 매개변수 자체를 const로 만드는 일은 드뭅니다.
포인터 매개변수를 선언할 때, 매개변수 자체를 상수로 만들기 위해 const를 사용하는 것은 큰 의미가 없습니다.
(호출자에게 아무 영향을 주지 않으며, 단지 함수 내에서 포인터가 다른 곳을 가리키지 않겠다는 문서화 역할 정도만 합니다)
반면, 함수가 전달된 객체를 수정할 수 있는지 여부를 나타내는 상수 값을 가리키는 포인터(pointer-to-const) 와 일반 포인터(pointer-to-non-const) 를 구분하는 것은 매우 중요합니다(호출자가 함수에 의해 원본 값이 바뀔 수 있는지를 알아야 하기 때문입니다).
void foo(const char* source, char* dest, int count); // 일반 포인터를 사용하면, 모든 const가 의미를 가집니다.
void foo(const char* const source, char* const dest, int count); // 매개변수 자체를 상수 포인터로 사용하면, 너무 많은 const 사이에 묻혀서 `dest`가 원본을 수정할 수 있는 포인터라는 사실을 눈치채기 어렵습니다.
첫 번째 경우, source는 변경 불가능한 값을 가리키고 dest는 변경 가능한 값을 가리킨다는 것을 한눈에 알 수 있습니다.
두 번째 경우에는 함수가 dest를 통해 원본 객체를 수정할 수 있다는 사실을 파악하기가 훨씬 더 어렵습니다!
모범 사례 (Best practice)
함수가 전달된 객체를 수정해야 하는 경우가 아니라면, 일반 포인터보다는 상수 값을 가리키는 포인터(pointer-to-const) 매개변수를 사용하는 것을 권장합니다. 또한, 특별한 이유가 없다면 함수 매개변수 포인터 자체를const포인터로 만들지 마세요.
이제 아무 문제 없어 보이는 다음 프로그램을 살펴봅시다.
#include <iostream>
void print(int* ptr)
{
std::cout << *ptr << '\n';
}
int main()
{
int x{ 5 };
print(&x);
int* myPtr {};
print(myPtr);
return 0;
}
이 프로그램이 실행되면 처음엔 5를 출력하겠지만, 그다음에는 십중팔구 비정상 종료(crash)될 것입니다.
print(myPtr)을 호출할 때 myPtr은 널(null) 포인터이므로, 함수 매개변수 ptr 역시 널 포인터가 됩니다.
함수 내부에서 이 널 포인터를 역참조하려고 시도하면 '정의되지 않은 동작(undefined behavior)'이 발생하여 프로그램이 뻗어버립니다.
주소로 매개변수를 전달할 때는 값을 역참조하기 전에 포인터가 널 포인터가 아닌지 확인하는 주의가 필요합니다.
가장 간단한 방법은 if 조건문을 사용하는 것입니다.
#include <iostream>
void print(int* ptr)
{
if (ptr) // ptr이 널 포인터가 아니라면
{
std::cout << *ptr << '\n';
}
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}
위 프로그램에서는 ptr을 역참조하기 전에 널인지 아닌지 테스트합니다. 이렇게 간단한 함수에서는 괜찮지만, 복잡한 함수에서는 여러 번 테스트를 해야 해서 코드가 지저분해지거나, 핵심 로직이 깊게 중첩(nesting)될 수 있습니다.
대부분의 경우에는 반대로 하는 것이 더 효과적입니다.
매개변수가 널인지 먼저 확인하고, 널이라면 즉시 함수를 빠져나오게(Early return) 하는 것입니다.
#include <iostream>
void print(int* ptr)
{
if (!ptr) // ptr이 널 포인터라면, 즉시 호출자에게 반환합니다
return;
// 이 지점에 도달했다면 ptr이 유효하다고 가정할 수 있으므로,
// 더 이상의 테스트나 중첩이 필요하지 않습니다.
std::cout << *ptr << '\n';
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}
만약 해당 함수에 절대 널 포인터가 전달되어서는 안 된다면, (이전 강의에서 배운) assert를 사용할 수도 있습니다.
(assert는 "절대 일어나선 안 될 일"을 명시하기 위한 도구입니다.)
#include <iostream>
#include <cassert>
void print(const int* ptr) // 이제 상수 int를 가리키는 포인터입니다
{
assert(ptr); // 널 포인터가 전달되면 디버그 모드에서 프로그램을 종료시킵니다 (이런 일은 절대 일어나선 안 되기 때문입니다)
// (선택 사항) 프로덕션 환경에서는 실제로 이런 일이 발생해도 프로그램이 뻗지 않도록 에러로 처리합니다
if (!ptr)
return;
std::cout << *ptr << '\n';
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}
위 예제의 print() 함수는 널 값을 제대로 '처리'한다기보다는 그냥 함수를 중단해버립니다. 그렇다면 애초에 사용자가 널 값을 전달하게 허용할 이유가 있을까요? 참조로 전달하기(Pass by reference) 는 '주소로 전달하기'의 모든 장점을 가지면서도, 실수로 널 포인터를 역참조할 위험이 전혀 없습니다.
이 밖에도 '상수 참조로 전달하기(Pass by const reference)'는 주소로 전달하기보다 몇 가지 더 유리한 점이 있습니다.
첫째, 주소로 전달하려면 객체가 반드시 '주소'를 가지고 있어야 합니다. 즉, 좌측값(lvalue)만 주소로 전달할 수 있고, 숫자 5 같은 우측값(rvalue)은 전달할 수 없습니다. 반면 상수 참조는 좌측값과 우측값을 모두 받을 수 있어 훨씬 유연합니다.
#include <iostream>
void printByValue(int val) // 함수 매개변수는 인수의 복사본입니다
{
std::cout << val << '\n'; // 복사본을 통해 값을 출력합니다
}
void printByReference(const int& ref) // 함수 매개변수는 인수에 바인딩된 참조입니다
{
std::cout << ref << '\n'; // 참조를 통해 값을 출력합니다
}
void printByAddress(const int* ptr) // 함수 매개변수는 인수의 주소를 담고 있는 포인터입니다
{
std::cout << *ptr << '\n'; // 역참조된 포인터를 통해 값을 출력합니다
}
int main()
{
printByValue(5); // 유효함 (단, 복사본을 만듦)
printByReference(5); // 유효함 (매개변수가 상수 참조이기 때문)
printByAddress(&5); // 오류: 우측값(r-value)의 주소를 가져올 수 없음
return 0;
}
둘째, 참조로 전달하는 구문이 훨씬 자연스럽습니다. 그냥 리터럴 값이나 객체를 넣기만 하면 됩니다.
반면 주소로 전달하려면 코드 여기저기에 앰퍼샌드(&)와 별표(*)가 지저분하게 흩어지게 됩니다.
현대 C++에서는 '주소로 전달하기'로 할 수 있는 대부분의 일을 다른 방법으로 더 깔끔하게 처리할 수 있습니다.
다음의 흔한 격언을 기억하세요. "가능하다면 참조로 전달하고, 반드시 필요할 때만 주소로 전달하라."
모범 사례 (Best practice)
주소로 전달해야만 하는 특별한 이유가 없다면, 항상 참조로 전달하기를 우선적으로 사용하세요.
주소로 전달하는 방식이 가장 흔하게 쓰이는 경우 중 하나는 함수가 "선택적인" 인자를 받을 수 있게 만들 때입니다.
말로 설명하는 것보다 예제를 보는 것이 훨씬 이해하기 쉽습니다.
#include <iostream>
void printIDNumber(const int *id=nullptr)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // 아직 사용자의 ID를 모릅니다
int userid { 34 };
printIDNumber(&userid); // 이제 사용자의 ID를 압니다
return 0;
}
이 프로그램의 출력 결과는 다음과 같습니다:
Your ID number is not known.
Your ID number is 34.
이 프로그램에서 printIDNumber() 함수는 주소로 전달되는 하나의 매개변수(id)를 가지며, 기본값은 nullptr로 설정되어 있습니다. main() 함수 안에서 이 함수를 두 번 호출합니다.
첫 번째 호출에서는 사용자의 ID를 모르기 때문에 아무런 인자 없이 호출합니다. 그러면 id 매개변수는 기본값인 nullptr을 가지게 되고, 함수는 "Your ID number is not known."을 출력합니다.
두 번째 호출에서는 유효한 ID가 생겼기 때문에 printIDNumber(&userid)처럼 변수의 주소를 넘겨줍니다.
id 매개변수가 userid의 주소를 받으므로, 함수는 "Your ID number is 34."를 출력하게 됩니다.
하지만 많은 경우, 위와 같은 결과는 함수 오버로딩(Function overloading)을 사용하는 것이 더 좋은 방법입니다.
#include <iostream>
void printIDNumber()
{
std::cout << "Your ID is not known\n";
}
void printIDNumber(int id)
{
std::cout << "Your ID is " << id << "\n";
}
int main()
{
printIDNumber(); // 아직 사용자의 ID를 모릅니다
int userid { 34 };
printIDNumber(userid); // 사용자의 ID가 34라는 것을 압니다
printIDNumber(62); // 이제 rvalue(우측값, 단순 숫자 등) 인자도 작동합니다
return 0;
}
오버로딩을 사용하면 여러 가지 장점이 있습니다. 포인터가 비어 있는지(null) 확인하지 않아도 되기 때문에 역참조 오류를 걱정할 필요가 없고, 단순한 숫자(리터럴)나 다른 우측값(rvalue)도 인자로 바로 전달할 수 있습니다.
주소를 함수에 전달할 때, 그 주소 값은 원본 인자에서 함수의 포인터 매개변수로 복사됩니다.
(주소를 복사하는 작업은 매우 빠르기 때문에 성능상 아무 문제가 없습니다)
다음 프로그램을 살펴볼까요?
#include <iostream>
// [[maybe_unused]]는 ptr2가 설정되었지만 사용되지 않았다는 컴파일러 경고를 없애줍니다
void nullify([[maybe_unused]] int* ptr2)
{
ptr2 = nullptr; // 함수 매개변수를 널 포인터로 만듭니다
}
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr은 x를 가리킵니다
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
nullify(ptr);
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
return 0;
}
이 프로그램의 출력 결과는 다음과 같습니다:
ptr is non-null
ptr is non-null
보시다시피, 함수 안에서 포인터 매개변수(ptr2)가 가진 주소를 바꿔도 원본 인자(ptr)가 가진 주소에는 아무런 영향을 주지 않습니다.
(ptr은 여전히 x를 가리킵니다) nullify() 함수가 호출될 때, ptr2는 원본 ptr이 가지고 있던 주소의 '복사본'을 받게 됩니다. 따라서 함수 안에서 ptr2가 가리키는 대상을 nullptr로 변경하더라도, 이는 복사본인 ptr2에만 영향을 미칠 뿐입니다.
그렇다면 함수 안에서 원본 포인터가 가리키는 대상 자체를 바꾸고 싶다면 어떻게 해야 할까요?
네, 가능합니다! 우리가 일반 변수를 참조(reference)로 전달할 수 있는 것처럼, 포인터도 참조로 전달할 수 있습니다.
위와 똑같은 프로그램이지만, ptr2를 주소에 대한 참조(reference to an address)로 바꾼 코드를 살펴보겠습니다.
#include <iostream>
void nullify(int*& refptr) // refptr은 이제 포인터에 대한 참조입니다
{
refptr = nullptr; // 함수 매개변수를 널 포인터로 만듭니다
}
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr은 x를 가리킵니다
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
nullify(ptr);
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
return 0;
}
이 프로그램의 출력 결과는 다음과 같습니다:
ptr is non-null
ptr is null
이제 refptr은 포인터에 대한 '참조'가 되었습니다. 따라서 원본 ptr이 인자로 전달되면, refptr은 곧 원본 ptr 그 자체와 하나로 묶이게(bound) 됩니다. 즉, 함수 안에서 refptr을 바꾸면 원본 ptr도 똑같이 바뀌게 됩니다.
참고로...
포인터에 대한 참조를 사용하는 일은 꽤 드물기 때문에, 문법을 헷갈리기 쉽습니다 (int*&인지int&*인지 헷갈리죠?).
다행인 것은 만약 거꾸로 쓴다면 컴파일러가 알아서 에러를 내준다는 점입니다.
(참조는 메모리에 존재하는 실물 객체가 아니기 때문에, '참조를 가리키는 포인터'는 만들 수 없기 때문입니다.)
에러가 나면 그때 순서를 바꿔 쓰시면 됩니다.
이 섹션에서는 왜 포인터를 비울 때 0이나 NULL을 사용하는 것이 좋지 않은지 설명합니다.
0은 정수로 해석될 수도 있고, 널 포인터(비어있는 포인터)로 해석될 수도 있습니다. 특정 상황에서는 우리가 둘 중 어떤 의도로 썼는지 모호해지며, 컴파일러가 우리의 의도와 다르게 해석해서 프로그램에 예상치 못한 오류를 일으킬 수 있습니다.NULL은 C++ 언어 표준에 그 형태가 딱 하나로 정해져 있지 않습니다. 컴파일러에 따라 0, 0L, ((void*)0) 등 완전히 다른 형태로 정의될 수 있습니다.이전에 함수 오버로딩에 대해 배웠던 것을 떠올려보세요. 함수에 전달되는 인자의 타입에 따라 컴파일러는 여러 오버로딩된 함수 중 알맞은 것을 찾아냅니다. 하지만 0이나 NULL을 쓰면 여기서 문제가 발생할 수 있습니다.
#include <iostream>
#include <cstddef> // NULL을 사용하기 위해 포함
void print(int x) // 이 함수는 정수를 받습니다
{
std::cout << "print(int): " << x << '\n';
}
void print(int* ptr) // 이 함수는 정수형 포인터를 받습니다
{
std::cout << "print(int*): " << (ptr ? "non-null\n" : "null\n");
}
int main()
{
int x{ 5 };
int* ptr{ &x };
print(ptr); // ptr의 타입이 int*이므로 항상 print(int*)를 호출합니다 (좋음)
print(0); // 0은 정수 리터럴이므로 항상 print(int)를 호출합니다 (우리가 의도한 바이기를 바랍니다)
print(NULL); // 이 구문은 컴파일러에 따라 다음 중 하나를 수행할 수 있습니다:
// print(int) 호출 (Visual Studio의 경우)
// print(int*) 호출
// 모호한 함수 호출로 인한 컴파일 에러 발생 (GCC와 Clang의 경우)
print(nullptr); // 항상 print(int*)를 호출합니다
return 0;
}
저자의 컴퓨터(Visual Studio 사용)에서는 다음과 같이 출력됩니다:
print(int): non-null
print(int): 0
print(int): 0
print(int): null
숫자 0을 넘겨주면, 컴파일러는 print(int*)보다 print(int)를 우선적으로 선택합니다.
만약 우리가 널 포인터를 의도하고 print(int*)가 호출되기를 바랐다면 전혀 엉뚱한 결과가 나오게 됩니다.
NULL의 경우에도 그것이 0으로 정의되어 있다면 print(int)가 호출됩니다.
다른 형태로 정의되어 있다면 에러가 나거나 의도치 않은 동작을 할 수 있습니다.
하지만 nullptr을 사용하면 이러한 모호함이 완전히 사라집니다.
nullptr은 오직 포인터 타입하고만 짝이 맞기 때문에 항상 안전하게 print(int*)를 호출합니다.
nullptr이 오버로딩에서 일반 정수와 구별될 수 있다는 것은, 자신만의 고유한 데이터 타입을 가지고 있다는 뜻입니다.
그렇다면 nullptr의 타입은 무엇일까요?
정답은 <cstddef> 헤더에 정의된 std::nullptr_t 입니다. std::nullptr_t는 오직 단 하나의 값, 바로 nullptr만 가질 수 있습니다!
조금 우스꽝스러워 보일 수 있지만, 오직 nullptr만 인자로 받아들이는 함수를 만들고 싶을 때 유용하게 쓰일 수 있습니다.
#include <iostream>
#include <cstddef> // std::nullptr_t를 사용하기 위해 포함
void print(std::nullptr_t)
{
std::cout << "in print(std::nullptr_t)\n";
}
void print(int*)
{
std::cout << "in print(int*)\n";
}
int main()
{
print(nullptr); // print(std::nullptr_t) 호출
int x { 5 };
int* ptr { &x };
print(ptr); // print(int*) 호출
ptr = nullptr;
print(ptr); // print(int*) 호출 (ptr의 타입이 int* 이므로)
return 0;
}
위 예제에서 print(nullptr)은 변환 과정이 필요 없기 때문에 print(int*) 대신 print(std::nullptr_t)를 호출합니다.
조금 헷갈릴 수 있는 부분은 ptr이 nullptr 값을 가지고 있을 때 print(ptr)을 호출하는 경우입니다.
함수 오버로딩은 '값'이 아니라 '타입'을 기준으로 매칭된다는 것을 기억하세요. ptr의 타입은 여전히 int*이기 때문에, 비록 비어있더라도 print(int*)가 호출됩니다. 포인터 타입은 std::nullptr_t로 암시적 변환이 되지 않으므로 print(std::nullptr_t)는 아예 고려 대상조차 되지 않습니다.
이 내용을 직접 쓸 일은 거의 없겠지만, 만약을 위해 알아두면 좋습니다.
이제 참조로 전달하기, 주소로 전달하기, 값으로 전달하기의 기본적인 차이를 이해하셨을 겁니다.
그럼 잠시 모든 것을 아주 단순하게(환원주의적으로) 생각해보겠습니다. :)
컴파일러가 참조(reference)를 완전히 최적화해서 없애버리는 경우도 많지만, 참조가 반드시 실재해야 하는 경우도 있습니다.
보통 컴파일러 내부에서 '참조'는 '포인터'를 사용하여 구현됩니다.
즉, 보이지 않는 곳에서 참조로 전달하기는 사실상 주소로 전달하기와 똑같이 동작합니다.
그리고 이전 레슨에서, 주소로 전달하기는 단순히 주소를 호출자에서 함수로 '복사'하는 것이라고 배웠습니다.
이것은 곧 주소를 값으로 전달하는 것에 불과합니다.
결론적으로, C++은 사실상 모든 것을 값으로 전달(Pass by value)한다고 볼 수 있습니다!
주소나 참조로 전달했을 때 원본이 바뀌는 특성은, 단지 우리가 넘겨받은 그 '주소 값'을 역참조(dereference)해서 원본에 접근할 수 있기 때문에 생겨나는 결과일 뿐입니다. 일반적인 값을 전달받았을 때는 불가능한 일이죠!
이전 강의에서 우리는 함수에 데이터를 넘겨줄 때 '값으로 전달(pass by value)'을 하면 데이터의 복사본이 만들어진다고 배웠습니다. 숫자나 문자 같은 기본 타입은 복사하는 데 걸리는 시간과 메모리가 적어서 괜찮습니다. 하지만 std::string과 같은 클래스 타입은 복사 비용이 꽤 비쌉니다. 그래서 우리는 비싼 복사 과정을 피하기 위해 대신 '(const) 참조로 전달'하거나 '주소로 전달'하는 방법을 사용합니다.
이와 똑같은 상황이 함수에서 값을 반환(return)할 때도 발생합니다. '값으로 반환(return by value)'을 하면 반환값의 복사본이 함수를 호출한 쪽으로 전달됩니다. 만약 반환하는 타입이 클래스라면, 이 복사 과정 역시 비용이 큽니다.
std::string returnByValue(); // std::string의 복사본을 반환합니다 (비용이 큼)
클래스 타입을 호출자에게 돌려줄 때, 우리는 복사본을 만드는 대신 '참조로 반환'할 수 있습니다.
참조로 반환하기는 반환할 원본 객체에 연결된 '참조(reference)'를 반환하므로, 쓸데없는 복사본을 만들지 않습니다.
방법은 아주 간단합니다. 함수의 반환 타입을 참조형(&)으로 정의하기만 하면 됩니다.
std::string& returnByReference(); // 기존 std::string에 대한 참조를 반환합니다 (비용이 적음)
const std::string& returnByReferenceToConst(); // 기존 std::string에 대한 const 참조를 반환합니다 (비용이 적음)
다음은 참조 반환이 어떻게 작동하는지 보여주는 간단한 예제 프로그램입니다.
#include <iostream>
#include <string>
const std::string& getProgramName() // const 참조를 반환합니다
{
static const std::string s_programName { "Calculator" }; // 정적(static) 지속 기간을 가짐, 프로그램 종료 시 소멸됨
return s_programName;
}
int main()
{
std::cout << "This program is named " << getProgramName();
return 0;
}
이 프로그램은 다음과 같이 출력합니다:
This program is named Calculator
getProgramName() 함수가 반환 타입을 const 참조(&)로 지정했기 때문에, return s_programName이 실행될 때 복사본을 만들지 않고 원본 s_programName에 대한 참조를 그대로 반환합니다. 호출한 main 함수에서는 이 참조를 사용해 원래 값에 접근하고 화면에 출력할 수 있습니다.
주의
참조로 반환되는 객체는 함수가 끝난 후에도 살아있어야 합니다.참조로 반환할 때 반드시 명심해야 할 가장 중요한 규칙이 있습니다.
참조가 가리키는 대상(객체)이 함수가 끝난 후에도 파괴되지 않고 살아있어야 한다는 것입니다.
그렇지 않으면 반환된 참조는 이미 파괴되어 사라진 메모리를 가리키게 되며(이를 댕글링 참조, Dangling reference라고 부릅니다), 이 참조를 사용하려고 하면 프로그램이 엉뚱하게 작동하거나 튕겨버립니다(미정의 동작).앞선 예제에서는
s_programName변수 앞에static(정적)키워드가 붙어 있었기 때문에, 프로그램이 완전히 끝날 때까지 변수가 파괴되지 않아 안전했습니다. 이제, 댕글링 참조를 반환하면 어떤 큰일이 벌어지는지 아래 코드로 확인해 봅시다.#include <iostream> #include <string> const std::string& getProgramName() { const std::string programName { "Calculator" }; // 이제 정적(static)이 아닌 지역 변수입니다. 함수가 끝나면 소멸됩니다. return programName; } int main() { std::cout << "This program is named " << getProgramName(); // 미정의 동작 (undefined behavior) 발생 return 0; }이 프로그램의 결과는 알 수 없습니다(미정의 동작).
getProgramName()함수가 끝날 때, 그 안에 있던 지역 변수programName은 메모리에서 자동으로 파괴됩니다.
즉, 함수는 '이미 파괴된 변수'를 가리키는 끊어진 참조를 반환하게 되고,main()함수에서 이를 출력하려고 하면 문제가 터지는 것입니다.요즘 컴파일러들은 이런 실수를 하면 경고나 에러를 띄워주지만, 코드가 복잡해지면 컴파일러도 알아채지 못할 수 있습니다.
핵심 경고
참조로 반환할 객체는 반드시 함수보다 수명이 길어야 합니다. 절대 일반 지역 변수나 임시 객체를 참조로 반환하지 마세요.
임시 값(잠깐 만들어지고 사라지는 값)을 참조로 반환하는 예제를 살펴보겠습니다.
#include <iostream>
const int& returnByConstReference()
{
return 5; // 임시 객체에 대한 const 참조를 반환합니다
}
int main()
{
const int& ref { returnByConstReference() };
std::cout << ref; // 미정의 동작 발생
return 0;
}
위 코드에서 함수는 숫자 5를 반환하는데, 반환 타입이 const int&입니다.
이렇게 되면 숫자 5를 담은 '임시 객체'가 만들어지고 그 참조가 반환됩니다.
하지만 함수가 끝나는 즉시 이 임시 객체는 파괴됩니다.
main() 함수에서 ref에 이 참조를 연결하려고 할 때는 이미 객체가 죽어버린 후라 늦었습니다.
결국 ref는 댕글링 참조가 됩니다.
아래는 조금 덜 명백하지만 똑같이 작동하지 않는 예제입니다.
#include <iostream>
const int& returnByConstReference(const int& ref)
{
return ref;
}
int main()
{
// 경우 1: 직접 바인딩
const int& ref1 { 5 }; // 수명 연장됨
std::cout << ref1 << '\n'; // 정상 작동
// 경우 2: 간접 바인딩
const int& ref2 { returnByConstReference(5) }; // 댕글링 참조에 바인딩됨
std::cout << ref2 << '\n'; // 미정의 동작 발생
return 0;
}
'경우 2'에서는 값 5가 임시 객체로 만들어져 함수로 전달됩니다. 함수는 그 참조를 그대로 반환하지만, 참조가 함수라는 '경계'를 한 번 거쳤기 때문에 C++의 수명 연장 규칙이 적용되지 않습니다. 결과적으로 임시 객체는 파괴되고 ref2는 위험한 상태가 됩니다.
핵심 경고
참조의 수명 연장 규칙은 함수 경계를 넘어서서 작동하지 않습니다.
처음 예제에서는 설명을 쉽게 하기 위해 const static 지역 변수를 참조로 반환했습니다.
하지만 const가 아닌 일반 static 지역 변수를 참조로 반환하는 것은 일반적으로 피해야 합니다. 아래 코드가 그 이유를 보여줍니다.
#include <iostream>
#include <string>
const int& getNextId()
{
static int s_x{ 0 }; // 주의: const 변수가 아닙니다
++s_x; // 다음 ID 생성
return s_x; // 그리고 그것에 대한 참조를 반환합니다
}
int main()
{
const int& id1 { getNextId() }; // id1은 참조입니다
const int& id2 { getNextId() }; // id2는 참조입니다
std::cout << id1 << id2 << '\n';
return 0;
}
이 프로그램은 12가 아니라 22를 출력합니다!
그 이유는 id1과 id2가 결국 완전히 똑같은 원본 데이터(s_x)를 바라보고 있기 때문입니다.
두 번째 호출에서 s_x의 값이 2로 바뀌어 버리니, id1을 출력할 때도 바뀐 값인 2가 나오는 것입니다.
이 문제를 해결하려면 id1과 id2를 참조(&)가 아닌 일반 변수로 만들어서, 참조 대신 값의 복사본을 저장하게 해야 합니다.
고급 독자를 위한 내용
문자열 정렬이나 상태 초기화의 어려움 등으로 인해 const가 아닌 정적 변수 참조 반환은 예기치 못한 버그를 만들기 쉽습니다.
특별한 이유가 없다면 무조건 피하는 것이 좋습니다.
함수가 참조를 반환하더라도, 그것을 '참조가 아닌 일반 변수'에 대입하거나 초기화하면 값이 복사됩니다.
#include <iostream>
#include <string>
const int& getNextId()
{
static int s_x{ 0 };
++s_x;
return s_x;
}
int main()
{
const int id1 { getNextId() }; // 이제 id1은 일반 변수이며 getNextId()에서 참조로 반환된 값의 복사본을 받습니다
const int id2 { getNextId() }; // 이제 id2는 일반 변수이며 getNextId()에서 참조로 반환된 값의 복사본을 받습니다
std::cout << id1 << id2 << '\n';
return 0;
}
여기서는 id1과 id2가 일반 변수이기 때문에 반환된 값을 자기만의 공간에 복사합니다. 따라서 출력은 예상대로 12가 됩니다.
물론, 함수가 처음부터 댕글링 참조(끊어진 참조)를 반환했다면, 복사를 시도하기도 전에 에러(미정의 동작)가 나버리니 주의해야 합니다.
#include <iostream>
#include <string>
const std::string& getProgramName() // const 참조를 반환할 것입니다
{
const std::string programName{ "Calculator" };
return programName;
}
int main()
{
std::string name { getProgramName() }; // 댕글링 참조의 복사본을 만듭니다
std::cout << "This program is named " << name << '\n'; // 미정의 동작 발생
return 0;
}
참조로 반환하는 것이 아주 유용하고 안전한 경우가 있습니다. 바로 함수의 인자로 들어온 참조를 그대로 다시 반환할 때입니다.
이게 안전한 이유는 논리적으로 당연합니다. 함수에 참조로 데이터를 넘겨줬다는 것은, 이미 함수를 호출한 쪽(바깥쪽)에 그 원본 데이터가 안전하게 존재한다는 뜻이기 때문입니다.
#include <iostream>
#include <string>
// 두 개의 std::string 객체를 받아 알파벳 순으로 먼저 오는 것을 반환합니다
const std::string& firstAlphabetical(const std::string& a, const std::string& b)
{
return (a < b) ? a : b; // 어떤 것이 알파벳 순으로 먼저인지 확인하기 위해 std::string에 operator<를 사용할 수 있습니다
}
int main()
{
std::string hello { "Hello" };
std::string world { "World" };
std::cout << firstAlphabetical(hello, world) << '\n';
return 0;
}
이 코드는 복사본을 하나도 만들지 않습니다!
값으로 주고받았다면 문자열 복사가 최대 3번 일어났겠지만, 참조로 주고받음으로써 속도와 메모리 모두 이득을 보았습니다.
함수에 참조(const가 아닌 일반 참조)로 데이터를 넘기면 함수 안에서 원본을 수정할 수 있듯이,
함수가 참조를 반환하면 호출한 쪽에서 반환된 원본을 수정할 수도 있습니다.
#include <iostream>
// 두 정수를 non-const 참조로 받아 더 큰 값을 참조로 반환합니다
int& max(int& x, int& y)
{
return (x > y) ? x : y;
}
int main()
{
int a{ 5 };
int b{ 6 };
max(a, b) = 7; // a와 b 중 더 큰 값을 7로 설정합니다
std::cout << a << b << '\n';
return 0;
}
max(a, b)는 더 큰 값인 b의 참조를 반환합니다. 거기에 = 7을 했으니 사실상 b = 7을 한 것과 똑같습니다. 그래서 결과는 57이 출력됩니다.
'주소로 반환하기'는 참조 대신 포인터(메모리 주소)를 반환한다는 점만 빼면 참조로 반환하는 것과 거의 똑같습니다.
함수가 끝난 후에도 객체가 살아있어야 한다는 주의사항도 동일합니다(그렇지 않으면 댕글링 포인터가 됩니다).
nullptr(빈 포인터)을 반환하여 "결과 없음"을 확실하게 표현할 수 있습니다. (예: 학생 목록에서 특정 학생을 찾는데, 못 찾으면 nullptr 반환)nullptr인지 항상 확인해야 합니다. 깜빡하고 빈 포인터를 그냥 사용해버리면 프로그램이 즉시 튕겨버립니다.모범 사례
"결과가 없음(nullptr)"을 표현해야 하는 특별한 상황이 아니라면, 안전성을 위해 항상 포인터보다는 '참조로 반환'하는 것을 선호하세요. (참고로 "결과 없음"을 더 안전하게 표현하려면 나중에 배울std::optional을 사용하는 것이 가장 좋습니다.)
함수와 그 함수를 호출하는 쪽(호출자)은 '매개변수'와 '반환값'을 통해 서로 데이터를 주고받습니다.
함수가 호출될 때 호출자는 인수를 넘겨주고, 함수는 매개변수를 통해 이를 받습니다.
이러한 인수는 값(value), 참조(reference), 또는 주소(address)의 형태로 전달될 수 있습니다.
보통은 값을 통째로 넘기거나 원본을 보호하는 const 참조 방식을 많이 사용하지만, 때로는 다른 방식이 필요할 때가 있습니다
대부분의 경우, 함수 매개변수는 호출자로부터 값을 입력받기 위한 용도로만 쓰입니다.
이렇게 입력만 받는 매개변수를 입력 매개변수(in parameter)라고 부릅니다.
#include <iostream>
void print(int x) // x는 입력 매개변수입니다
{
std::cout << x << '\n';
}
void print(const std::string& s) // s는 입력 매개변수입니다
{
std::cout << s << '\n';
}
int main()
{
print(5);
std::string s { "Hello, world!" };
print(s);
return 0;
}
입력 매개변수는 보통 '값(pass by value)'이나 'const 참조(pass by const reference)' 형태로 전달됩니다.
상수가 아닌 참조(non-const reference)나 포인터를 사용해 인수를 전달하면, 함수 안에서 넘겨받은 원본 객체의 값을 직접 수정할 수 있습니다. 이는 단순히 반환값(return)을 사용하는 것만으로는 정보 전달이 충분하지 않을 때, 함수가 호출자에게 데이터를 돌려주는 훌륭한 방법이 됩니다.
이렇게 오로지 호출자에게 정보를 돌려주기 위한 목적으로 쓰이는 매개변수를 출력 매개변수(out parameter)라고 부릅니다.
예를 들어보겠습니다.
#include <cmath> // std::sin() 및 std::cos() 함수 사용을 위해
#include <iostream>
// sinOut과 cosOut은 출력 매개변수입니다
void getSinCos(double degrees, double& sinOut, double& cosOut)
{
// sin()과 cos()은 각도(degree)가 아닌 라디안(radian)을 사용하므로 변환이 필요합니다
constexpr double pi { 3.14159265358979323846 }; // 파이(pi) 값
double radians = degrees * pi / 180.0;
sinOut = std::sin(radians);
cosOut = std::cos(radians);
}
int main()
{
double sin { 0.0 };
double cos { 0.0 };
double degrees{};
std::cout << "Enter the number of degrees: ";
std::cin >> degrees;
// getSinCos 함수는 변수 sin과 cos에 결과값을 담아 반환합니다
getSinCos(degrees, sin, cos);
std::cout << "The sin is " << sin << '\n';
std::cout << "The cos is " << cos << '\n';
return 0;
}
이 함수는 하나의 입력 매개변수 degrees(값으로 전달됨)를 받고, 두 개의 출력 매개변수를 (참조를 통해) "반환"합니다.
여기서는 출력용이라는 것을 명확히 표시하기 위해 매개변수 이름 끝에 "Out"이라는 단어를 붙였습니다.
이렇게 하면 처음에 변수에 어떤 값이 들어있는지는 전혀 중요하지 않고, 함수 안에서 새로운 값으로 덮어씌워질 것임을 쉽게 알 수 있습니다. 관례상 출력 매개변수는 주로 매개변수 목록의 가장 오른쪽에 둡니다.
어떻게 작동하는지 더 자세히 알아볼까요?
먼저 main 함수에서 sin과 cos라는 지역 변수를 만듭니다.
이 변수들은 값의 복사본이 아닌 '참조' 방식으로 getSinCos() 함수에 전달됩니다.
즉, getSinCos() 함수는 복사본이 아니라 main()에 있는 실제 sin과 cos 변수에 직접 접근할 수 있다는 뜻입니다.
따라서 getSinCos()가 (참조 매개변수인 sinOut과 cosOut을 통해) 새로운 값을 할당하면, 원래의 sin과 cos 변수 값이 새로운 값으로 덮어씌워집니다. 그런 다음 main() 함수에서 이 업데이트된 값들을 출력하게 되는 것이죠.
만약 sin과 cos를 참조가 아니라 '값'으로 넘겼다면, getSinCos()는 원본이 아닌 복사본만 만지작거리게 되어 함수가 끝나는 순간 변경된 내용이 모두 사라졌을 것입니다. 하지만 참조로 넘겼기 때문에 변경 사항이 함수 밖에서도 그대로 유지되는 것입니다. 우리는 이 원리를 이용해 여러 개의 값을 호출자에게 돌려줄 수 있습니다.
출력 매개변수는 유용하지만 몇 가지 단점이 있습니다.
const로 만들 수도 없습니다.다음 예제는 이 두 가지 단점을 잘 보여줍니다.
#include <iostream>
int getByValue()
{
return 5;
}
void getByReference(int& x)
{
x = 5;
}
int main()
{
// 값으로 반환
[[maybe_unused]] int x{ getByValue() }; // 객체를 초기화하는 데 사용할 수 있음
std::cout << getByValue() << '\n'; // 수식 내에서 임시 반환값을 직접 사용할 수 있음
// 출력 매개변수로 반환
int y{}; // 값을 할당받을 객체를 먼저 만들어야 함
getByReference(y); // 그런 다음 함수에 전달하여 원하는 값을 할당받음
std::cout << y << '\n'; // 그 후에야 해당 값을 사용할 수 있음
return 0;
}
보시다시피, 출력 매개변수를 사용하는 문법은 조금 번거롭고 부자연스럽습니다.
함수의 반환값을 변수에 대입할 때는 해당 변수의 값이 바뀔 거라는 게 매우 분명합니다.
x = getByValue(); // x가 수정된다는 것이 매우 명확함
이는 코드를 읽는 사람에게 x의 값이 변할 것이라는 점을 직관적으로 알려주므로 좋은 방식입니다.
하지만 아까 보았던 getSinCos() 함수의 호출 형태를 다시 살펴볼까요?
getSinCos(degrees, sin, cos);
이 줄만 봐서는 degrees가 입력용이고 sin과 cos가 출력용이라는 것을 알아채기가 어렵습니다.
프로그래머가 sin과 cos 값이 바뀐다는 사실을 인지하지 못하면 심각한 논리적 오류가 발생할 수 있습니다.
이럴 때 참조 대신 '주소(포인터)'로 값을 전달하면, 인수를 넘길 때 변수 앞에 주소 연산자(&)를 붙여야 하므로 값이 바뀔 수 있다는 점을 조금 더 명확하게 알릴 수 있습니다.
다음 예제를 확인해 보세요.
void foo1(int x); // 값으로 전달
void foo2(int& x); // 참조로 전달
void foo3(int* x); // 주소로 전달
int main()
{
int i{};
foo1(i); // i를 수정할 수 없음
foo2(i); // i를 수정할 수 있음 (명확하지 않음)
foo3(&i); // i를 수정할 수 있음 (기호 & 덕분에 조금 더 명확함)
int *ptr { &i };
foo3(ptr); // i를 수정할 수 있음 (포인터를 전달하므로 명확하지 않을 수 있음)
return 0;
}
foo3(&i)를 호출할 때 i 대신 &i를 넘기는 것을 주목하세요. 이 기호 덕분에 i 값이 수정될 수 있겠구나 하고 예상하기가 더 쉬워집니다.
하지만 이 방식도 완벽하지는 않습니다.
foo3(ptr)처럼 이미 만들어진 포인터를 넘길 때는 &를 붙일 필요가 없어서, 여전히 값이 변한다는 것을 눈치채기 어려울 수 있습니다.
게다가 포인터를 사용하면 빈 포인터(nullptr)가 전달될 가능성도 생깁니다. 따라서 함수 내부에서 이 값이 비어있는지 확인하고 처리하는 코드를 추가해야 하므로 로직이 더 복잡해집니다. 포인터 검사 로직을 추가하느니 차라리 그냥 참조(reference) 방식을 유지하는 것이 문제가 적을 때가 많습니다.
이러한 여러 가지 이유 때문에, 다른 좋은 대안이 없는 경우를 제외하고는 출력 매개변수 사용은 피하는 것이 좋습니다.
권장 사항 (Best practice)
- 다른 대안이 없는 드문 경우를 제외하고는 출력 매개변수 사용을 피하세요.
- 꼭 출력 매개변수를 써야 한다면 포인터 방식보다는 참조(reference) 방식을 선호하는 것이 좋습니다.
아주 드물게, 함수가 출력 매개변수의 기존 값을 먼저 읽고 나서 새 값으로 덮어쓰는 경우가 있습니다.
이렇게 입력과 출력 역할을 동시에 하는 매개변수를 입출력 매개변수(in-out parameter)라고 합니다.
입출력 매개변수 역시 출력 매개변수와 동일하게 작동하며, 앞서 설명한 단점들도 모두 똑같이 가지고 있습니다.
non-const reference)로 전달해야 할 때데이터를 복사하는 비용을 아끼기 위해 참조로 넘길 때는, (내용이 변경되지 않도록) 거의 항상 const 참조를 사용해야 합니다.
저자의 메모
이어지는 예제들에서는 우리가 관심 있는 특정 데이터 타입을Foo라고 부르겠습니다.
지금은Foo를 여러분이 잘 아는 타입(예를 들어std::string)이라고 상상하시면 됩니다.
하지만 상수가 아닌 일반 참조를 사용하는 것이 더 나은 두 가지 대표적인 상황이 있습니다.
첫째, 매개변수가 입출력(in-out) 매개변수인 경우입니다.
우리가 무언가를 수정해서 다시 돌려받기 위해 원본 객체를 함수에 넘기는 것이라면, 그냥 그 원본 객체를 직접 수정하는 것이 코드가 단순해지고 성능상으로도 유리합니다.
void someFcn(Foo& inout)
{
// inout 객체를 수정함
}
int main()
{
Foo foo{};
someFcn(foo); // 함수 호출 후 foo가 수정됨, 한눈에 파악하기 어려울 수 있음
return 0;
}
함수 이름을 직관적으로 잘 지어주면 이런 혼란을 줄일 수 있습니다.
void modifyFoo(Foo& inout)
{
// inout 객체를 수정함
}
int main()
{
Foo foo{};
modifyFoo(foo); // 함수 호출 후 foo가 수정됨, 조금 더 명확해짐
return 0;
}
이 방식 대신 평소처럼 객체를 const 참조로 넘기고, 함수 내부에서 새로운 객체를 만들어 값으로 반환한 뒤 이를 원본 변수에 덮어씌우는 방법도 있습니다.
Foo someFcn(const Foo& in)
{
Foo foo { in }; // 여기서 복사가 발생함
// foo를 수정함
return foo;
}
int main()
{
Foo foo{};
foo = someFcn(foo); // foo가 수정된다는 것이 명확하지만, 여기서 복사가 한 번 더 발생함
return 0;
}
이 방식은 코드를 읽기가 편하고 전통적인 반환 문법을 따른다는 장점이 있지만, 눈에 보이지 않는 복사가 두 번이나 더 일어난다는 단점이 있습니다 (물론 똑똑한 컴파일러라면 이 중 하나 정도는 최적화로 없애주기도 합니다).
둘째, 함수가 객체를 값으로 반환해야 하지만, 그 객체를 복사하는 비용이 '매우' 큰 경우입니다.
특히 프로그램 성능이 아주 중요한 부분에서 해당 함수가 엄청나게 많이 호출된다면, 복사 비용을 줄이기 위해 이 방법을 쓸 수 있습니다.
void generateExpensiveFoo(Foo& out)
{
// out 객체를 수정함
}
int main()
{
Foo foo{};
generateExpensiveFoo(foo); // 함수 호출 후 foo가 수정됨
return 0;
}
심화 학습
위 상황에 해당하는 가장 흔한 예시는, 함수가 크기가 아주 거대한 배열(C스타일 배열이나std::array)에 데이터를 채워 넣어야 하는데 배열 요소 복사 비용이 심하게 비싼 경우입니다. 배열에 대해서는 나중 챕터에서 다루겠습니다.
하지만 굳이 비정상적인 반환 방법까지 동원해야 할 정도로 객체의 복사 비용이 무거운 상황은 현실에서 매우 드물다는 점을 알아두세요.
이전에 '10.8강 - auto 키워드를 사용한 객체의 타입 추론'에서, auto 키워드를 쓰면 컴파일러가 초기값을 보고 변수의 타입을 스스로 알아내게(추론하게) 할 수 있다고 배웠습니다.
int main(){
int a { 5 };
auto b { a }; // b의 타입은 int로 추론됨
return 0;
}
또한, 기본적으로 타입 추론 과정에서 const는 제거된다는 점도 살펴보았습니다.
int main(){
const double a { 7.8 }; // a의 타입은 const double
auto b { a }; // b의 타입은 double (const가 제거됨)
constexpr double c { 7.8 }; // c의 타입은 const double (constexpr은 암시적으로 const를 적용함)
auto d { c }; // d의 타입은 double (const가 제거됨)
return 0;
}
만약 추론된 타입에 const나 constexpr을 다시 적용하고 싶다면, 변수를 선언할 때 직접 적어주면 됩니다.
int main(){
double a { 7.8 }; // a의 타입은 double
const auto b { a }; // b의 타입은 const double (const가 적용됨)
constexpr double c { 7.8 }; // c의 타입은 const double (constexpr은 암시적으로 const를 적용함)
const auto d { c }; // d의 타입은 const double (const가 제거되었다가, 다시 적용됨)
constexpr auto e { c }; // e의 타입은 constexpr double (const가 제거되었다가, constexpr이 다시 적용됨)
return 0;
}
타입 추론은 const를 제거할 뿐만 아니라, 참조(&) 기호도 함께 제거합니다.
#include <string>
std::string& getRef(); // 참조를 반환하는 임의의 함수
int main(){
auto ref { getRef() }; // 타입은 std::string으로 추론됨 (std::string& 가 아님)
return 0;
}
위 예제에서 ref 변수는 타입 추론을 사용하고 있습니다.
getRef() 함수가 std::string&(참조형)을 반환함에도 불구하고, 참조 속성이 제거되어 ref의 타입은 단순한 std::string이 됩니다.
const의 경우와 마찬가지로, 추론된 타입이 참조형이 되기를 원한다면 변수를 선언할 때 참조(&) 기호를 다시 붙여주면 됩니다.
#include <string>
std::string& getRef(); // 참조를 반환하는 임의의 함수
int main(){
auto ref1 { getRef() }; // std::string (참조가 제거됨)
auto& ref2 { getRef() }; // std::string& (참조가 제거되었다가, 다시 적용됨)
return 0;
}
C++에서 const는 적용되는 위치에 따라 두 가지로 나뉩니다.
| 개념 | 설명 | 예시 |
|---|---|---|
| 최상위 const | 변수(객체) 자체를 상수로 만듭니다. | const int x; (x 자체가 상수) |
| 하위 const | 변수가 참조하거나 가리키는 대상을 상수로 만듭니다. | const int& ref; (참조하는 대상이 상수) |
참고로, 참조형(&)은 구조상 이미 그 자체로 변경할 수 없으므로, 최상위 const 문법이 따로 존재하지 않습니다.
(항상 암시적으로 최상위 const 취급을 받습니다) 반면, 포인터는 최상위, 하위, 또는 둘 다 가질 수 있습니다.
const int* const ptr; // 왼쪽의 const는 하위 const, 오른쪽의 const는 최상위 const입니다.
타입 추론이 const를 제거한다고 할 때, 오직 '최상위 const'만 제거됩니다. 대상을 보호하는 '하위 const'는 유지됩니다.
초기값이 const 참조형일 경우, 먼저 참조(&)가 제거됩니다.
그 결과로 남은 const는 이제 객체 자체를 수식하는 최상위 const가 되므로 함께 제거됩니다.
#include <string>
const std::string& getConstRef(); // const 참조를 반환하는 임의의 함수
int main(){
auto ref1{ getConstRef() }; // std::string (참조가 제거되고, 결과에서 최상위 const도 제거됨)
return 0;
}
위 예제에서 함수는 const std::string&를 반환합니다. 여기서 참조(&)가 먼저 떨어져 나가면 const std::string만 남게 됩니다. 이때의 const는 변수 자체를 뜻하는 최상위 const가 되므로, 결국 이마저도 제거되어 최종 타입은 std::string이 됩니다.
핵심 포인트
참조(&)를 제거하면 하위 const가 최상위 const로 바뀔 수 있습니다.
원한다면 참조와 const를 다시 명시적으로 붙여줄 수 있습니다.
#include <string>
const std::string& getConstRef(); // const 참조를 반환하는 임의의 함수
int main(){
auto ref1{ getConstRef() }; // std::string (참조와 최상위 const가 모두 제거됨)
const auto ref2{ getConstRef() }; // const std::string (참조와 const가 제거된 후, const가 다시 적용됨)
auto& ref3{ getConstRef() }; // const std::string& (참조가 제거되었다가 다시 적용됨, 하위 const는 유지됨)
const auto& ref4{ getConstRef() }; // const std::string& (참조가 제거되었다가 다시 적용됨, 하위 const 유지됨)
return 0;
}
ref3의 경우가 흥미롭습니다. 원래라면 참조가 제거되어야 하지만, 우리가 &를 다시 붙여주었으므로 참조형이 유지됩니다.
따라서 타입은 const std::string&로 남게 되고, 이때의 const는 하위 const이므로 제거되지 않습니다.
모범 사례 (Best Practice)
const 참조가 필요하다면, 문법상 굳이 필요 없는 상황이더라도 명시적으로const를 적어주는 것이 좋습니다.
코드의 의도가 명확해지고 실수를 줄일 수 있습니다.
constexpr은 표현식의 타입 자체에 포함되는 개념이 아닙니다. 따라서 auto는 constexpr을 추론해 내지 못합니다.
#include <string_view>
#include <iostream>
constexpr std::string_view hello { "Hello" }; // 암시적으로 const가 적용됨
constexpr const std::string_view& getConstRef() // 함수는 constexpr이며, const std::string_view& 를 반환함
{
return hello;
}
int main(){
auto ref1{ getConstRef() }; // std::string_view (참조와 최상위 const가 모두 제거됨)
constexpr auto ref2{ getConstRef() }; // constexpr const std::string_view (참조와 최상위 const가 제거된 후, constexpr이 적용되어 암시적으로 const가 됨)
auto& ref3{ getConstRef() }; // const std::string_view& (참조가 다시 적용되었고, 하위 const는 유지됨)
constexpr const auto& ref4{ getConstRef() }; // constexpr const std::string_view& (참조가 다시 적용되었고, 하위 const 유지, constexpr 적용됨)
return 0;
}
참조와는 달리, 타입 추론은 포인터(*)를 제거하지 않습니다.
#include <string>
std::string* getPtr(); // 포인터를 반환하는 임의의 함수
int main(){
auto ptr1{ getPtr() }; // std::string*
return 0;
}
타입이 포인터라는 것을 코드를 읽는 사람에게 명확히 알려주기 위해, auto 대신 auto*를 사용할 수도 있습니다.
#include <string>
std::string* getPtr(); // 포인터를 반환하는 임의의 함수
int main(){
auto ptr1{ getPtr() }; // std::string*
auto* ptr2{ getPtr() }; // std::string*
return 0;
}
왜 참조는 제거되는데 포인터는 유지되나요?
참조를 사용할 때는 사실상 그 '대상 객체'를 다루는 것입니다. 따라서 참조형 자체보다는 대상의 타입을 추론하는 것이 논리적입니다.
반면 포인터는 객체의 '메모리 주소'를 담고 있는 별개의 변수입니다.
포인터를 평가할 때는 포인터 그 자체를 다루는 것이므로, 포인터 타입을 그대로 유지하는 것이 맞습니다.
복잡한 내용들 때문에 머리가 아프셨다면 위로의 말씀을 드립니다! 가장 중요한 핵심만 빠르게 다시 짚어보겠습니다.
&) 기호를 제거합니다. 이 과정에서 하위 const가 최상위 const로 변할 수 있습니다.constexpr은 애초에 타입 추론의 대상이 아니므로, 원한다면 직접 적어주어야 합니다.*)는 제거하지 않고 그대로 유지합니다.const, &, constexpr 같은 키워드는 프로그래머의 의도를 명확히 하기 위해 직접 명시적으로 적어주는 습관을 들이는 것이 좋습니다.auto* 고려하기: 포인터 타입을 추론할 때는 auto*를 사용하는 것을 권장합니다. 초기값이 포인터가 아닐 경우 컴파일러가 에러를 내주어 실수를 방지할 수 있습니다.'9.4 레슨 - 오류 감지 및 처리'에서는 함수가 스스로 적절히 해결할 수 없는 오류를 만나는 경우에 대해 이야기했습니다.
예를 들어, 값을 계산해서 반환하는 다음 함수를 살펴봅시다.
int doIntDivision(int x, int y)
{
return x / y;
}
만약 이 함수를 호출하는 쪽에서 의미상 유효하지 않은 값(예: y = 0)을 전달하면, 이 함수는 반환할 값을 계산할 수 없습니다(수학적으로 0으로 나누는 것은 정의되어 있지 않기 때문입니다). 이럴 땐 어떻게 해야 할까요? 계산을 수행하는 함수는 다른 부작용(side effects)을 일으키면 안 되므로, 함수 스스로 이 오류를 해결하는 것은 적절하지 않습니다. 이런 경우에는 일반적으로 함수가 오류를 감지하게 한 다음, 함수를 호출한 쪽으로 오류를 돌려보내서 프로그램 흐름에 맞게 직접 처리하도록 합니다.
앞서 언급한 레슨에서는 함수가 호출자에게 오류를 반환하는 두 가지 방법을 배웠습니다.
void) 함수가 성공 또는 실패를 나타내는 bool 값을 반환하도록 하기.두 번째 방법의 예로, 사용자가 x에 유효하지 않은 인수를 전달하면 절대 나올 수 없는 값인 0.0을 반환하는 reciprocal()(역수 구하기) 함수를 살펴보겠습니다.
#include <iostream>
// x의 역수는 1/x 이며, x=0이면 0.0을 반환합니다.
double reciprocal(double x)
{
if (x == 0.0) // x가 의미상 유효하지 않은 값이라면
return 0.0; // 오류가 발생했음을 알리기 위해 센티널 값인 0.0을 반환합니다.
return 1.0 / x;
}
void testReciprocal(double d)
{
double result { reciprocal(d) };
std::cout << "The reciprocal of " << d << " is ";
if (result != 0.0)
std::cout << result << '\n';
else
std::cout << "undefined\n";
}
int main()
{
testReciprocal(5.0);
testReciprocal(-4.0);
testReciprocal(0.0);
return 0;
}
이 방법도 꽤 괜찮아 보이지만, 몇 가지 단점이 있습니다.
위에서 본 doIntDivision() 함수를 생각해 봅시다. 사용자가 y에 0을 전달하면 어떤 값을 오류로 반환해야 할까요? 0을 사용할 수는 없습니다. 왜냐하면 어떤 수를 0으로 나눈 결과가 아니라, 0을 다른 수로 나누면 0이라는 정상적인 결과가 나올 수 있기 때문입니다. 사실 이 함수에서는 자연스럽게 나오지 않는 '특별한' 반환값이 아예 존재하지 않습니다.
그럼 우리는 어떻게 해야 할까요?
첫째, 아주 드물게 나올 법한 반환값을 센티널 값으로 골라 오류를 나타내는 데 사용할 수 있습니다.
#include <limits> // std::numeric_limits를 사용하기 위해 포함
// 실패 시 std::numeric_limits<int>::lowest()를 반환합니다.
int doIntDivision(int x, int y)
{
if (y == 0)
return std::numeric_limits<int>::lowest();
return x / y;
}
std::numeric_limits<T>::lowest()는 T 타입이 가질 수 있는 가장 작은(가장 큰 음수) 값을 반환하는 함수입니다. 이것은 '9.5 레슨 - std::cin과 잘못된 입력 처리'에서 소개했던 std::numeric_limits<T>::max() (가장 큰 양수 값을 반환)와 반대되는 개념입니다.
위의 예제에서 doIntDivision()이 계속 진행될 수 없다면, std::numeric_limits<int>::lowest()를 반환하여 호출자에게 함수가 실패했음을 알립니다.
이 방법은 대체로 잘 작동하지만 두 가지 단점이 있습니다.
std::numeric_limits<int>::lowest()와 같은지 비교해서 실패 여부를 검사해야 합니다. 코드가 길어지고 보기 흉해집니다.doIntDivision(std::numeric_limits<int>::lowest(), 1)을 호출하면, 반환되는 결과값은 std::numeric_limits<int>::lowest()가 됩니다. 이때 이 반환값이 함수가 성공해서 나온 정상 값인지, 실패해서 나온 오류 값인지 헷갈리게 됩니다. 함수를 어떻게 사용하느냐에 따라 문제가 될 수도 있고 아닐 수도 있지만, 우리가 신경 써야 할 또 다른 골칫거리이자 프로그램에 오류가 발생할 수 있는 잠재적 위험입니다.둘째, 반환값으로 오류를 알리는 것을 포기하고, 예외(exceptions)와 같은 다른 메커니즘을 사용할 수 있습니다. 하지만 예외 처리는 그 자체로 복잡하고 성능 비용이 발생하며, 상황에 따라 적절하지 않거나 원치 않을 수 있습니다. 이런 간단한 연산에 예외를 사용하는 것은 약간 과한 느낌(overkill)이 있습니다.
셋째, 하나의 값만 반환하는 것을 포기하고 두 개의 값을 반환할 수 있습니다. 하나는 함수가 성공했는지를 나타내는 bool 타입 값이고, 다른 하나는 실제 반환값(성공 시)이거나 의미 없는 값(실패 시)을 담는 원래 원했던 타입의 값입니다. 이 방법이 여러 선택지 중 가장 좋은 방법일 것입니다.
C++17 이전에는 이 세 번째 방법을 선택하려면 프로그래머가 직접 코드를 짜서 구현해야 했습니다. C++에서 이를 구현할 수 있는 여러 가지 방법이 있긴 하지만, 직접 만들다 보면 필연적으로 일관성이 떨어지고 오류가 생기기 쉽습니다.
C++17에서는 '선택적인(optional) 값'을 구현하는 클래스 템플릿 타입인 std::optional을 도입했습니다.
즉, std::optional<T>는 T 타입의 값을 '가질 수도 있고', '가지지 않을 수도' 있습니다.
이를 사용하여 앞서 말한 세 번째 옵션을 깔끔하게 구현할 수 있습니다.
#include <iostream>
#include <optional> // std::optional을 사용하기 위해 포함 (C++17)
// 이제 우리 함수는 선택적으로 int 값을 반환합니다.
std::optional<int> doIntDivision(int x, int y)
{
if (y == 0)
return {}; // 또는 std::nullopt를 반환합니다.
return x / y;
}
int main()
{
std::optional<int> result1 { doIntDivision(20, 5) };
if (result1) // 함수가 값을 반환했다면
std::cout << "Result 1: " << *result1 << '\n'; // 값을 가져옵니다.
else
std::cout << "Result 1: failed\n";
std::optional<int> result2 { doIntDivision(5, 0) };
if (result2)
std::cout << "Result 2: " << *result2 << '\n';
else
std::cout << "Result 2: failed\n";
return 0;
}
이 코드의 출력 결과는 다음과 같습니다.
Result 1: 4
Result 2: failed
std::optional을 사용하는 방법은 아주 쉽습니다. 값을 넣어서 생성할 수도 있고, 값 없이 생성할 수도 있습니다.
std::optional<int> o1 { 5 }; // 값으로 초기화
std::optional<int> o2 {}; // 값 없이 초기화
std::optional<int> o3 { std::nullopt }; // 값 없이 초기화
std::optional 안에 값이 있는지 확인하려면 다음 방법 중 하나를 선택하면 됩니다.
if (o1.has_value()) // has_value()를 호출하여 o1에 값이 있는지 확인
if (o2) // bool로의 암시적 변환을 사용하여 o2에 값이 있는지 확인
std::optional에서 값을 가져오려면 다음 방법 중 하나를 선택하면 됩니다.
std::cout << *o1; // 역참조하여 o1에 저장된 값을 가져옵니다. (값이 없는데 시도하면 미정의 동작 발생)
std::cout << o2.value(); // value()를 호출하여 o2에 저장된 값을 가져옵니다. (값이 없으면 std::bad_optional_access 예외 발생)
std::cout << o3.value_or(42); // value_or()를 호출하여 o3에 저장된 값을 가져옵니다. (값이 없으면 기본값인 `42` 반환)
std::optional의 사용 문법이 기본적으로 포인터와 거의 똑같다는 점에 주목하세요.
| 동작 | 포인터 (Pointer) | std::optional |
|---|---|---|
| 값을 가지지 않음 | {} 또는 std::nullptr 로 초기화/할당 | {} 또는 std::nullopt 로 초기화/할당 |
| 값을 가짐 | 주소로 초기화/할당 | 값으로 초기화/할당 |
| 값이 있는지 확인 | bool로의 암시적 변환 | bool로의 암시적 변환 또는 has_value() |
| 값 가져오기 | 역참조 (*) | 역참조 (*) 또는 value() |
하지만 의미상(semantically)으로 포인터와 std::optional은 완전히 다릅니다.
std::optional은 값 의미론(value semantics)을 갖습니다. 즉, 실제로 내부에 자신의 값을 가지고 있으며, 할당을 하면 '값'이 복사됩니다. 우리가 std::optional을 값으로 반환하면, std::optional 객체 자체(그 안에 들어있는 값 포함)가 호출자에게 안전하게 복사됩니다. 즉, std::optional을 사용하면 함수 안에서 만들어진 값을 호출자에게 문제없이 돌려줄 수 있습니다.이 점을 염두에 두고 앞의 예제가 어떻게 작동하는지 살펴봅시다. 이제 doIntDivision()은 int 대신 std::optional<int>를 반환합니다. 함수 내부에서 오류를 감지하면 {}를 반환하는데, 이는 아무 값도 담고 있지 않은 비어있는 std::optional을 암시적으로 반환하는 것입니다. 올바른 값이 있다면 그 값을 반환하며, 이는 해당 값을 쏙 담고 있는 std::optional을 암시적으로 반환합니다.
main() 함수 안에서는 반환된 std::optional에 값이 있는지 확인하기 위해 bool로의 암시적 변환을 사용합니다. 값이 있다면 std::optional 객체를 역참조(*)하여 값을 꺼내 사용합니다. 값이 없다면 오류 처리 조건문을 실행합니다. 정말 간단하죠!
std::optional을 반환하는 것은 여러모로 훌륭합니다.
std::optional을 사용하면, 이 함수가 값을 반환할 수도 있고 안 할 수도 있다는 사실을 코드 자체로 명확히 보여줍니다(문서화 효과).std::optional을 사용하는 문법이 편리하고 직관적입니다.하지만 몇 가지 단점도 존재합니다.
std::optional 안에 진짜로 값이 들어있는지 반드시 확인해야 합니다. 만약 값이 없는 std::optional을 역참조하면 알 수 없는 오류(미정의 동작)가 발생합니다.std::optional은 함수가 '왜' 실패했는지에 대한 상세한 이유를 전달할 방법은 제공하지 않습니다.여러분의 함수가 실패한 이유를 자세히 알려줘야 하는 상황(실패 원인을 더 잘 파악하거나 여러 종류의 실패를 구분하기 위해)이 아니라면, 무언가를 반환하거나 실패할 가능성이 있는 함수에는 std::optional이 최고의 선택입니다.
모범 사례 (Best practice)
실패할 가능성이 있는 함수를 만들 때는 센티널 값 대신std::optional을 반환하세요. 단, 함수가 실패한 '이유'에 대한 추가적인 정보가 필요한 경우는 제외입니다.
관련 내용
C++23에 도입된std::expected는 함수가 정상적으로 기대하는 값이나 예상치 못한 오류 코드를 반환할 수 있는 상황을 처리하기 위해 만들어졌습니다. 자세한 정보는std::expected레퍼런스를 참고하세요.
'12.11 레슨 - 주소로 전달하기 파트 2'에서는 함수가 "선택적인(optional)" 인수를 받도록 하기 위해 어떻게 '주소로 전달(pass by address)' 방식을 사용하는지 논의했습니다. (즉, 호출자가 인수를 생략하고 싶을 때 nullptr을 넘기거나, 아니면 실제 객체의 주소를 넘기는 방식입니다.) 하지만 이 방식의 단점 중 하나는, nullptr이 아닌 진짜 인수를 전달할 때는 그 인수가 반드시 메모리 상의 주소를 가진 값(lvalue)이어야 한다는 것입니다.
이름에서 이미 눈치채셨겠지만, std::optional은 함수가 선택적 인수를 받을 수 있는 또 다른 대안입니다 (단, 함수 안으로 값을 집어넣기만 하는 in-parameter 용도로 사용됩니다). 기존의 이런 방식 대신:
#include <iostream>
void printIDNumber(const int *id=nullptr)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // 아직 사용자의 ID를 모릅니다.
int userid { 34 };
printIDNumber(&userid); // 이제 사용자의 ID를 알게 되었습니다.
return 0;
}
아래와 같이 사용할 수 있습니다.
#include <iostream>
#include <optional>
void printIDNumber(std::optional<const int> id = std::nullopt)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // 아직 사용자의 ID를 모릅니다.
int userid { 34 };
printIDNumber(userid); // 이제 사용자의 ID를 알게 되었습니다.
printIDNumber(62); // 62와 같은 임시값(rvalue)도 바로 전달할 수 있습니다!
return 0;
}
이 접근 방식에는 두 가지 장점이 있습니다.
std::optional 내부에서 복사본을 만들기 때문에) 변수에 담지 않은 임시값(rvalue)도 바로 전달할 수 있습니다.하지만, std::optional은 인수의 복사본을 만들기 때문에 타입 T가 std::string처럼 복사하는 데 비용이 많이 드는 무거운 타입일 경우 문제가 됩니다. 일반적인 함수 매개변수에서는 이를 피하기 위해 매개변수를 const lvalue 참조(const T&)로 만들어서 복사가 일어나지 않게 했습니다. 아쉽게도 C++23 기준으로 std::optional은 내부적으로 참조(reference)를 지원하지 않습니다.
그러므로, T가 평소에 값으로 전달(pass by value) 해도 무리가 없는 가벼운 타입일 때만 std::optional<T>를 선택적 매개변수로 사용하는 것을 권장합니다. 복사 비용이 비싼 타입이라면 기존처럼 const T*(포인터)를 사용하세요.
std::optional이 참조를 직접 지원하지는 않지만, std::reference_wrapper ('17.5 레슨'에서 다룰 예정)를 사용하면 참조처럼 흉내 낼 수 있습니다. std::string 속성을 가진 Employee 구조체와 std::reference_wrapper를 사용했을 때 위의 프로그램이 어떻게 달라지는지 살펴봅시다.
#include <functional> // std::reference_wrapper를 사용하기 위해 포함
#include <iostream>
#include <optional>
#include <string>
struct Employee
{
std::string name{}; // 복사 비용이 비쌉니다.
int id;
};
void printEmployeeID(std::optional<std::reference_wrapper<Employee>> e=std::nullopt)
{
if (e)
std::cout << "Your ID number is " << e->get().id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printEmployeeID(); // 아직 Employee 정보를 모릅니다.
Employee e { "James", 34 };
printEmployeeID(e); // 이제 Employee의 ID를 알게 되었습니다.
return 0;
}
비교를 위해, 동일한 작업을 포인터로 구현한 버전을 보겠습니다.
#include <iostream>
#include <string>
struct Employee
{
std::string name{}; // 복사 비용이 비쌉니다.
int id;
};
void printEmployeeID(const Employee* e=nullptr)
{
if (e)
std::cout << "Your ID number is " << e->id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printEmployeeID(); // 아직 Employee 정보를 모릅니다.
Employee e { "James", 34 };
printEmployeeID(&e); // 이제 Employee의 ID를 알게 되었습니다.
return 0;
}
이 두 프로그램은 사실상 거의 똑같이 작동합니다. 저희는 첫 번째 방식이 두 번째 방식보다 더 읽기 쉽거나 유지보수하기 좋다고 생각하지 않으며, 굳이 두 가지 새로운 복잡한 타입을 프로그램에 끌어들일 가치가 없다고 봅니다.
많은 경우에, 함수 오버로딩(function overloading)이 훨씬 더 나은 해결책을 제공합니다.
#include <iostream>
#include <string>
struct Employee
{
std::string name{}; // 복사 비용이 비쌉니다.
int id;
};
void printEmployeeID()
{
std::cout << "Your ID number is not known.\n";
}
void printEmployeeID(const Employee& e)
{
std::cout << "Your ID number is " << e.id << ".\n";
}
int main()
{
printEmployeeID(); // 아직 Employee 정보를 모릅니다.
Employee e { "James", 34 };
printEmployeeID(e); // 이제 Employee의 ID를 알게 되었습니다.
printEmployeeID( { "Dave", 62 } ); // 임시값(rvalues)도 전달할 수 있습니다!
return 0;
}
모범 사례 (Best practice)
선택적인 값을 반환해야 할 때는std::optional을 우선적으로 사용하세요.
선택적인 함수 매개변수가 필요할 때는 (가능하다면) 함수 오버로딩을 우선적으로 사용하세요. 오버로딩이 여의치 않고T가 일반적으로 값으로 전달하기 가벼운 타입이라면std::optional<T>를 사용하세요.T를 복사하는 비용이 비싸다면const T*를 사용하는 것이 좋습니다.