C++ 함수의 매개변수에 &와 const가 붙는 이유
값 전달과 참조 전달의 차이를 실행 결과로 비교하고, Chromium 함수의 선언을 읽어봅니다.
이전 글에서는 헤더에 함수를 정의했을 때 생기는 중복 정의 문제와 inline의 역할을 살펴봤다. Chromium의 실제 코드를 보면서 다음 선언도 만났다.
inline bool InitLogging(const LoggingSettings& settings) {
return BaseInitLoggingImpl(settings);
}
당시에는 inline과 함수 본문의 위치에 집중했다. 이번에는 const LoggingSettings&에 담긴 의미를 이해해 보자.
『열혈 C++ 프로그래밍』 2장의 참조자와 함수 내용을 바탕으로, 같은 값을 함수에 전달하더라도 매개변수의 타입에 따라 무엇이 달라지는지 살펴본다. C의 기본 문법과 함수를 알고 C++을 배우기 시작한 독자를 대상으로 한다.
먼저, 인자와 매개변수를 구분하자
void PrintNumber(int value) {
std::cout << value << "\n";
}
int main() {
int number = 10;
PrintNumber(number);
}
PrintNumber(number)에서 함수에 전달하는 number가 **인자(argument)**이고, 함수 선언의 value가 **매개변수(parameter)**다.
호출할 때 작성한 값으로 매개변수를 초기화한다. 이때 매개변수가 int인지, int&인지, const int&인지에 따라 원본과의 관계가 달라진다. 위 코드는 설명용 일부이며, 아래부터는 필요한 헤더를 포함한 실행 예제를 사용한다.
값 전달: 원본과 별도의 변수를 만든다
먼저 익숙한 int 매개변수를 보자. 다음 코드를 by_value.cc에 저장한다.
#include <iostream>
void ChangeValue(int value) {
value = 20;
std::cout << "inside: " << value << "\n";
}
int main() {
int number = 10;
ChangeValue(number);
std::cout << "outside: " << number << "\n";
}
macOS에서 clang++를 사용할 수 있다면 다음 명령으로 실행할 수 있다.
clang++ -std=c++17 by_value.cc -o by_value
./by_value
출력은 다음과 같다.
inside: 20
outside: 10
매개변수 value는 인자 number의 값으로 초기화한 별도의 변수다. 함수 안에서 value를 바꾸어도 number는 그대로다. 이를 **값 전달(pass by value)**이라고 한다.
number: 10 ── 값을 전달 ──→ value: 10
↓ 대입
value: 20
number는 여전히 10
이 그림은 프로그램에서 두 변수를 구분하기 위한 설명이다. 실제로 어떤 메모리나 CPU 레지스터를 사용하는지까지 나타내는 그림은 아니다.
참조: 기존 변수에 다른 이름으로 접근한다
함수로 넘어가기 전에 참조 자체를 보자.
int number = 10;
int& alias = number;
alias = 20;
// number도 20이다.
int& alias는 정수를 참조하는 이름 alias를 선언한다. alias에 별도의 정수 값을 복사해 두는 것이 아니라, 이미 있는 number를 가리키도록 연결한다. 처음에는 같은 변수에 붙인 다른 이름으로 이해하면 좋다.
여기서 &는 선언 위치에 있으므로 참조 타입을 나타낸다. &number처럼 식에서 변수 앞에 붙여 주소를 얻는 연산과는 사용 위치가 다르다.
참조를 만들 때는 대상을 정해야 하며, 한번 정한 대상을 다른 대상으로 바꿀 수 없다.
int first = 10;
int second = 30;
int& alias = first;
alias = second;
// first에 second의 값인 30을 대입한다.
// alias가 second를 참조하도록 바뀌는 것은 아니다.
즉, 참조의 초기화와 참조를 통한 대입은 다르다. 참조의 초기화·대입 규칙은 C++ 표준 초안의 참조 초기화 항목에서도 확인할 수 있다.
참조 전달: 함수 안에서도 원본을 사용한다
이제 매개변수를 int에서 int&로 바꿔 보자. by_reference.cc에 저장한다.
#include <iostream>
void ChangeReference(int& value) {
value = 20;
std::cout << "inside: " << value << "\n";
}
int main() {
int number = 10;
ChangeReference(number);
std::cout << "outside: " << number << "\n";
}
clang++ -std=c++17 by_reference.cc -o by_reference
./by_reference
이번 출력은 다음과 같다.
inside: 20
outside: 20
매개변수 value가 number를 참조하도록 초기화된다. 함수 안의 value = 20;은 원본인 number에 대입하는 것이다. 이런 방식을 **참조 전달(pass by reference)**이라고 한다.
number ─┐
├→ 같은 정수 변수: 10 → 20
value ─┘
호출하는 코드는 여전히 ChangeReference(number)다. C에서 포인터를 전달할 때처럼 &number라고 작성하지 않는다. 호출 표현만 보고 원본이 바뀔 수 있는지 판단하기 어려우므로, 함수의 매개변수 타입도 확인해야 한다.
단, int&가 보인다고 반드시 원본을 변경한다는 뜻은 아니다. 해당 참조를 통해 변경할 수 있다는 뜻이다. 이번 예제에서는 실제로 대입하기 때문에 원본이 달라졌다.
const 참조: 이 참조를 통해 원본을 변경하지 않는다
원본을 참조해서 읽고 싶지만, 이 경로로 값을 변경하지 못하게 하려면 const int&를 사용할 수 있다. by_const_reference.cc에 저장한다.
#include <iostream>
void PrintReference(const int& value) {
std::cout << "inside: " << value << "\n";
}
int main() {
int number = 10;
PrintReference(number);
std::cout << "outside: " << number << "\n";
}
clang++ -std=c++17 by_const_reference.cc -o by_const_reference
./by_const_reference
출력은 다음과 같다.
inside: 10
outside: 10
이번에는 value가 읽기 전용 경로가 된다. 함수 본문에 아래 대입을 추가하면 컴파일 오류가 발생한다.
void PrintReference(const int& value) {
value = 20; // 오류: const 참조를 통해 정수 값을 변경하려고 한다.
}
const는 이 경우 참조하는 정수를 변경하지 못하도록 제한한다. 다만 원본 변수 자체를 모든 곳에서 변경 불가능하게 만드는 것은 아니다.
int number = 10;
const int& view = number;
number = 20;
// view로 읽어도 20이다.
원본인 number는 const 변수가 아니므로 다른 경로로 변경할 수 있다. view는 스냅샷이나 복사본이 아니라 같은 변수를 읽는다.
또한 const int&에는 10 같은 값도 전달할 수 있다.
PrintReference(10); // 가능
// ChangeReference(10); // 오류: int&에는 이런 값을 직접 연결할 수 없다.
임시 값과 참조의 상세한 수명 규칙은 다음 글에서 살펴볼 내용이다. 여기서는 int&와 const int&가 받아들일 수 있는 인자에도 차이가 있다는 정도로 구분하자. 참조 초기화 규칙
세 가지 선언을 비교하면
다음은 서로 다른 매개변수 형태를 비교한 표다. 실제 예제에서는 어떤 함수가 호출되는지 분명하도록 함수 이름도 다르게 사용했다.
| 매개변수 | 같은 타입의 기존 변수를 전달했을 때 | 매개변수를 통해 원본 변경 | 이번 글의 예시 |
|---|---|---|---|
int value |
별도의 정수 변수를 초기화한다 | 불가능 | ChangeValue |
int& value |
원본을 참조한다 | 가능 | ChangeReference |
const int& value |
원본을 읽기 전용 경로로 참조한다 | 불가능 | PrintReference |
이 표에서 “원본 변경 불가능”은 이 정수 매개변수를 통한 변경을 뜻한다. 전역 변수나 다른 경로까지 포함해서 함수에 부작용이 전혀 없다는 뜻은 아니다.
포인터를 값으로 전달하는 경우처럼, 별도로 생각해야 할 타입도 있다. 이번 글의 값 전달 예제는 정수에 한정한다.
참조로 전달하면 항상 빠를까?
int처럼 작고 단순한 값은 값으로 전달하는 방식이 자연스럽다. 참조가 원본을 사용한다는 이유만으로 무조건 더 빠르다고 판단할 수는 없다.
반면 문자열이나 경로처럼 내부에 여러 데이터를 담는 객체는, 함수에 전달할 때 객체를 복사하는 비용이 발생할 수 있다. 기존 객체를 const 참조로 받으면 읽기 위해 매개변수용 객체를 복사하는 일을 피할 수 있다.
다만 이 설명도 같은 타입의 기존 객체를 참조에 직접 연결하는 상황을 기준으로 한다. 다른 타입에서 변환이 필요한 인자 등을 전달하면 임시 객체가 만들어질 수 있다. const T&는 모든 복사와 객체 생성을 없애는 문법이 아니다. 여기서 T는 설명을 위해 쓴 타입 이름으로, int나 나중에 볼 FilePath 등을 대입해 읽으면 된다.
실행 속도는 타입과 함수 본문, 컴파일러의 최적화 등에 따라 달라진다. 이 글의 실행 예제는 원본과의 관계를 확인한 것이며, 성능을 측정한 예제는 아니다.
Chromium의 PathExists 선언 읽기
Chromium의 base/files/file_util.h에 있는 PathExists 선언을 보자.
BASE_EXPORT bool PathExists(const FilePath& path);
FilePath는 파일 경로를 표현하는 Chromium의 타입이다. 내부 구현과 클래스 문법을 아직 몰라도 다음 정보는 읽을 수 있다.
bool: 함수의 결과는true또는false다. 선언 위의 주석은 지정한 경로가 존재하면true, 그렇지 않으면false를 반환한다고 설명한다.const FilePath& path: 전달받은 경로 객체를const참조로 받는다. 같은 타입의 기존 객체를 전달하면 그 객체를 참조한다.BASE_EXPORT: Chromium에서 심볼을 외부에 공개하는 것과 관련된 매크로다. 이번에는 매개변수 형태에 집중하면 된다.
함수는 경로를 입력으로 받아 사용할 수 있고, 이 참조를 통해 경로 객체를 일반적으로 수정하는 연산은 할 수 없다. 그러나 const만으로 함수가 다른 상태를 변경하지 않는다고 결론 내릴 수는 없다.
실제로 같은 헤더의 DeleteFile 선언도 경로를 const 참조로 받는다.
BASE_EXPORT bool DeleteFile(const FilePath& path);
이 함수는 경로 객체를 입력으로 받지만, 이름과 주석이 설명하듯 파일이나 빈 디렉터리를 삭제하는 기능을 한다. 경로를 담은 객체를 수정하는 것과, 그 경로가 가리키는 파일을 삭제하는 것은 다른 일이다. const는 함수 전체가 아니라 해당 타입과 접근 경로에 적용되는 제한으로 읽어야 한다.
이전 글의 const LoggingSettings& settings도 같은 방식으로 읽을 수 있다. 설정 객체를 const 참조로 받는다는 뜻이며, 이 문법만으로 설정 객체의 내부 구조까지 알아야 할 필요는 없다.
Chromium 코드를 읽을 때 가이드는 어떻게 활용할까?
Chromium은 자체 예외를 제외하고 Google C++ 스타일 가이드를 따른다. Google 가이드의 함수 입력·출력 지침은 필수 입력을 보통 값 또는 const 참조로 받고, 반환값을 통해 결과를 전달하는 방식을 출력 매개변수보다 우선하도록 권장한다.
예를 들어 아래 두 함수는 모두 계산 결과를 호출자에게 전달할 수 있다.
// 계산한 결과를 반환한다.
int AddOne(int value) {
return value + 1;
}
// 인자로 받은 원본을 변경한다.
void Increment(int& value) {
++value;
}
호출자는 각각 다음처럼 사용한다.
int number = 10;
int next = AddOne(number); // number는 10, next는 11
Increment(number); // number는 11
두 함수는 원본을 변경하는지 여부가 다르다. “참조를 배웠으니 결과는 모두 참조로 돌려주자”라고 접근하기보다, 새 결과를 반환할지 기존 값을 변경할지 먼저 정해야 한다. 가이드의 권고도 모든 참조 매개변수를 금지한다는 뜻은 아니다.
포인터가 등장하면 추가로 확인할 내용도 있다. Chromium의 소유권 지침은 포인터 매개변수에서 객체의 소유권을 전달하는지 구분하도록 한다. 값과 참조의 차이를 이해한 뒤에는 “이 함수는 객체를 사용만 하는가, 정리할 책임까지 넘겨받는가?”로 질문을 확장할 수 있다. 이 내용은 new와 delete를 다룰 글에서 살펴보자.
핵심 요약
매개변수 타입은 함수가 인자를 어떤 관계로 사용하는지 알려준다.
- 값 전달: 매개변수는 별도의 값이다. 정수 매개변수를 바꾸어도 원본 정수는 바뀌지 않는다.
- 참조 전달: 매개변수를 통해 원본에 접근한다. 변경 가능한 참조라면 그 경로로 원본을 바꿀 수 있다.
- const 참조 전달: 원본을 읽기 위한 경로로 사용할 수 있다. 같은 타입의 기존 객체를 직접 참조하는 경우 매개변수용 객체의 복사를 피할 수 있다.
Chromium에서 const FilePath& 같은 선언을 만나면, 단순히 & 기호를 외우기보다 무엇을 참조하며, 그 경로로 무엇을 변경할 수 있는지 읽어 보자.
주의할 점
- 참조라고 반드시 원본을 변경하는 것은 아니다. 변경할 수 있는지와 실제로 변경하는지는 다르다.
- const 참조가 원본을 영구적으로 고정하지는 않는다. 다른 경로로 원본이 바뀌면 참조로 읽는 값도 달라질 수 있다.
- const 매개변수가 함수 전체의 부작용을 막지는 않는다. 객체의 상태와 외부 자원의 상태도 구분해야 한다.
- 참조 전달이 항상 더 빠르지는 않다. 작은 값의 전달과 복사 비용이 큰 객체의 전달을 같은 기준으로 판단하지 않는다.
- 참조가 대상의 수명을 자동으로 보장하지는 않는다. 대상이 사라진 뒤의 참조 문제는 다음 글에서 다룬다.
참고 자료
- C++ 표준 초안: 참조
- C++ 표준 초안: 참조 초기화
- Google C++ 스타일 가이드: Functions의 Inputs and Outputs 항목
- Chromium C++ 스타일 가이드