본문 바로가기
NC University/Advanced C++

4일차 - 완벽한 전달자 (perfect forwarding) (1)

by 날쑤 2015. 7. 23.

Intro

  앞에서 다룬 std::function과 std::bind를 이용한 간단한 예제 하나를 살펴보자.

  맨 마지막 cout의 출력값은 얼마일까? 아마도 의도대로라면 참조인자를 0으로 바꾸는 함수를 호출했으니 0이 출력되어야 맞다. 그런데 코드를 수행해보면 10이 출력된다. 여기서 무슨 일이 일어났는지 파악하기 위해 STL보다 좀 더 간단한 예제로 접근해보자.

문제의 단순화 - lockAndCall

  lockAndCall은 멀티 스레드 환경에서 동기화와 함수 호출을 한방에 해결하기 위해 만든 wrapper function template이다. (물론 인자를 하나만 받을 수 있고, 반환형이 없다는 제한이 있지만 일단은 무시하자.)

  그런데 이 코드도 앞의 예제처럼 마지막의 x값이 처음과 같다. 이유는 함수 템플릿의 인스턴스화가 어떻게 일어날지를 생각해보면 명확해진다. 두 번째 lockAndCall이 호출될 때 컴파일러는 lockAndCall<void(int&), int> 함수 코드를 생성한다. 즉, 인자 역할을 할 a의 타입이 int가 된다. 그러므로 호출 시 x는 a로 값 복사가 되고, goo가 호출될 때는 x의 참조가 아닌 x의 복사본인 a의 참조가 넘겨지게 된다.

  결국 처음 예제에서 일어난 상황도 위와 동일하다. bind는 함수 템플릿이다. 예제에서는 정수형 변수를 인자로 넘겼으니 bind의 첫번째 가변 인자의 타입은 int가 될 것이고, 여기서 값 복사가 일어난다. 그래서 f를 호출한 이후에도 x의 값은 변동이 없었던 것이다.

해결책 1 - 함수 오버로딩을 이용

  만약에 lockAndCall에서 a의 타입 자체를 A&로 선언하면 어떨까? 그러면 위의 경우는 a의 타입이 int&가 되어서 문제가 해결될 거 같다. 하지만 이를 적용하고 3번째 호출의 주석을 제거하면 여기서 컴파일이 안된다. 에러 메시지를 확인해보면 2번째 인자를 int에서 int&로 변환할 수 없다고 나온다. 이유는 함수 템플릿 인스턴스화에 있다.

  이 호출에서 사용되는 템플릿 함수 인스턴스는 lockAndCall<void(int), int>이다. 따라서 lockAndCall의 두번째 파라메터의 타입은 int&가 된다. 하지만 C++ 문법상 일반 참조는 상수값을 담을 수가 없고, 상수값을 담으려면 상수 참조를 써야한다. 하지만 a의 타입을 상수 참조로 선언하면 상수 참조를 일반 참조로 변환할 수 없다고 goo를 호출하는 부분에서 에러가 발생한다. 결국 이를 해결하려면 참조를 적용하되 함수 오버로딩을 이용해서 두 케이스를 모두 고려해야한다.

  이렇게 함수 오버로딩을 적용해서 lockAndCall을 두개로 만들면 변수로 호출하는 경우는 일반 참조 쪽으로, 상수로 호출하는 경우는 상수 참조 쪽을 통해서 전달된다. 이제는 컴파일 문제도 없고 인자도 '완벽'하게 전달할 수 있다. 하지만 이것은 함수의 인자가 '하나'일 때만 유효하다.

  만약 인자가 2개라면 위와 같은 식으로 4개의 함수를 만들어야하고, 일반적으로 인자가 N개일 경우에는 총 2^N개의 함수 구현이 필요하다. 타이핑의 문제 뿐만 아니라 템플릿의 특성상 타입이 변함에 따라 생성되는 코드량이 비약적으로 증가하는 것도 큰 문제다. 그러므로 인자의 개수가 많아질 경우에 이 방법은 분명히 한계가 있다.

해결책 2 - 이동 가능한 참조 만들기

  C++에서의 '참조'는 대입시 참조 자체가 아닌 값이 이동한다. 이게 무슨 말인지는 아래의 코드를 살펴보자.

  '참조'는 포인터와 유사하게 어떤 변수의 위치를 가리키고 있지만, 한번 참조한 곳을 변경할 수는 없다. 그리고 참조를 다른 참조에 대입하면 대입당하는 참조가 가리키는 변수 자체가 바뀌는 게 아니라 참조가 가리키는 변수의 값이 바뀐다. 위의 예제에서 r2를 r1에 대입한 후의 결과는 이것을 잘 설명한다. 만약 참조 대입이 참조 이동을 의미했다면 r1은 r2가 가리키는 n2를 참조하게 되었을 것이다. 그러므로 출력값은 10, 20, 20, 20이 되어야한다. 하지만 실제 출력값은 20, 20, 20, 20이 나온다. 즉, 참조의 대입을 통해 값의 이동이 일어난 것이다.

  그럼 이제는 대입시 값의 이동이 아닌 참조의 이동이 일어나는 객체(movable reference)를 만들어보자. 위에서 언급한 것처럼 참조는 포인터와 성격이 유사하다는 것을 생각하면 구현 자체는 어렵지 않다.

  my_reference_wrapper를 이용해서 이전 코드의 참조 선언 부분을 다음과 같이 수정하자.

  my_reference_wrapper의 복사 생성자 및 대입 연산자는 컴파일러가 자동으로 생성한다. 이들은 멤버변수를 얕은 복사한다. 그러므로 대입 이후에 r1의 pObj와 r2의 pObj는 둘 다 n2의 주소를 가리키고, 출력 값은 10, 20, 20, 20이 나온다. 즉, my_reference_wrapper는 참조와 유사한 기능을 하면서 대입 시에는 값의 이동이 아닌 참조의 이동이 일어난다. 이동 가능한 참조를 만들었으니 다시 처음의 lockAndCall 예제(no overloading)로 돌아가보자.

  만약에 goo와 x를 lockAndCall로 넘길 때 x의 참조를 넘기면 어떻게 될까? 역시 x의 값에 영향을 주지 못한다. 왜냐하면 함수 템플릿에서 타입 인자가 추론될 때 표현식이 참조 타입이면 참조는 무시되기 때문이다. 즉, A는 int&가 아니라 int로 추론된다. 하지만 x를 my_reference_wrapper로 감싸면 결과가 달라진다.

  이제는 my_reference_wrapper를 통해 x의 참조가 goo로 전달된다. 즉, 별도의 함수 오버로딩 없이 완벽한 전달이 가능해진 것이다. 결과값도 0이 출력됨을 확인할 수 있다. 추가로 위와 같이 참조의 전달을 위해 타입이름을 모두 치는것은 번거로우니 my_reference_wrapper를 만들어주는 헬퍼 함수가 있으면 편리할 것이다. 그래서 아래와 같이 헬퍼 함수도 추가하자. 최종적으로 다음과 같은 코드를 얻게된다.

정리

  일반적으로 함수 템플릿을 통해 다른 함수를 호출하는 경우 인자의 전달이 제대로 되지 않으면 원래 의도했던 것과는 다른 결과가 나오게 된다. 이는 찾기 힘든 버그의 시발점이 될 수 있을 것이다. 그러므로 함수 템플릿을 통해 다른 함수를 호출 할때에는 대상 함수로 올바른 타입의 인자가 전달되어야 하는데, 이를 완벽한 전달(perfect forwarding)이라고 한다.

  위에서는 lockAndCall 예제를 통해 인자의 완벽한 전달을 위한 두 가지 방법을 제시했다. 그 중, 첫 번째 방법인 함수 오버로딩을 이용한 방법은 인자의 개수가 적을때는 쓸만하지만 인자의 개수가 많아지면 다소 문제가 있었다. 그래서 두번째 이동가능한 참조를 이용한 방법을 구현했다. 이는 별도의 함수 오버로딩없이 인자의 완벽한 전달이 가능하게 해준다.

  C++ 표준에서도 이동가능한 참조가 이미 구현되어 있는데, 이는 위에서 구현한 것과 별로 차이가 나지 않는다. 아래 코드는 표준에 구현되어 있는 이동 가능한 참조를 이용해서 인자를 완벽하게 전달하는 과정을 보여준다.