글에 사용된 모든 그림과 내용은 직접 만들고 작성한 것입니다.
언리얼 엔진에서 universal reference를 어떻게 다루는지, C++에선 어떻게 다루는지에 대해 정리하기 위함. 그리고 template을 사용해 중복된 함수를 하나의 함수로 만드는 방법에 대해 정리하기 위함.
- universal reference는 rvalue, lvalue 둘 다 의미하는 용어이다.
- 언리얼 엔진에서는 std::remove_reference_t를 사용해 universal reference를 다룬다.
https://learn.microsoft.com/en-us/cpp/cpp/rvalue-reference-declarator-amp-amp?view=msvc-170
https://learn.microsoft.com/en-us/cpp/cpp/lvalue-reference-declarator-amp?view=msvc-170
https://learn.microsoft.com/en-us/cpp/cpp/references-cpp?view=msvc-170
https://learn.microsoft.com/ko-kr/cpp/standard-library/remove-reference-class?view=msvc-170
https://learn.microsoft.com/ko-kr/cpp/cpp/move-constructors-and-move-assignment-operators-cpp?view=msvc-170
microsoft 공식 홈페이지에서는 reference을 다음처럼 소개하고 있습니다.
"A reference, like a pointer, stores the address of an object that is located elsewhere in memory. Unlike a pointer, a reference after it's initialized can't be made to refer to a different object or set to null. The & operator signifies an lvalue reference and the && operator signifies either an rvalue reference, or a universal reference (either rvalue or lvalue) depending on the context. "
레퍼런스는 포인터와 같이 메모리의 주소 값을 저장합니다. 포인터는 초기화가 되고 난 이후에도 다른 주소값을 가지거나 nullptr로 설정할 수 있는 반면에 레퍼런스는 다른 메모리 주소값을 가리키거나 null로 설정할 수 없습니다. 설정을 보면 포인터가 만능인데 왜 레퍼런스를 쓸까요? C++의 Move Semantic 때문입니다. 사용자가 지정한 operator+ 같은 연산자 오버로딩을 잘 만들어두면 연산의 결과를 담아두는 임시 객체가 생겨나지 않게 됩니다. 결과 값을 바로 다음 연산에 사용할 수 있습니다.
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s = string("h") + "e" + "ll" + "o";
cout << s << endl;
}
& 는 lvalue, &&는 rvalue, &, && 둘다 될 수 있는 것이 universal reference입니다.
// rvalue 참조
void function(Cat&& object)
// rvalue 참조
Cat&& p1 = Cat("Persian cat", 2010);
// universal 참조
// var1는 auto를 사용해 명시적으로 나타낼 수 없습니다.
auto && var1 = var2
// rvalue 참조
// param은 std::vector를 사용해 명시적으로 나타냈고, vector의 내부 객체인 T만 문맥에 따라 구분됩니다.
template<typename T>
void VectorFunction(std::vector<T>&& param);
// universal 참조
// param 자체를 T로 선언했기 때문에 명시적으로 타입을 알 수 없습니다.
template<typename T>
void UniversalFunction(T&& param);
위의 예시를 보면 알 수 있듯이 여러 타입을 사용할 수 있는 template 함수를 만들 때 universal 참조의 예를 많이 만나게 됩니다. 바로 이럴때 std::forward를 사용해 값을 전달합니다. std::move는 rvalue에 사용합니다.
std::move를 사용하는 예시를 먼저 보겠습니다.
#include <iostream>
#include <vector>
#include <string>
#include <utility> // std::move를 사용하기 위함
class Cat {
public:
std::string name;
int birthYear;
Cat(const std::string& n, int y) : name(n), birthYear(y) {
std::cout << "Constructor: " << name << ", " << birthYear << std::endl;
}
// Move Constructor
Cat(Cat&& other) noexcept
: name(std::move(other.name)), birthYear(other.birthYear) {
std::cout << "Move Constructor called for " << name << std::endl;
}
// Copy Constructor
Cat(const Cat& other)
: name(other.name), birthYear(other.birthYear) {
std::cout << "Copy Constructor called for " << name << std::endl;
}
void Info() const {
std::cout << "Cat: " << name << ", born in " << birthYear << std::endl;
}
};
// rvalue 참조를 받는 함수
void function(Cat&& object) {
std::cout << "[function] received an rvalue reference:\n";
object.Info();
}
// rvalue 참조를 받는 템플릿 함수
template<typename T>
void VectorFunction(std::vector<T>&& param) {
std::cout << "[VectorFunction] received vector of size " << param.size() << std::endl;
for (auto& item : param) {
item.Info();
}
}
int main() {
// std::move를 이용해서 rvalue로 캐스팅
Cat a("Siberian", 2015);
function(std::move(a)); // std::move로 rvalue로 바꿔 전달
// rvalue 참조 변수에 rvalue를 대입
Cat&& p1 = Cat("Persian cat", 2010); // 임시 객체를 rvalue 참조에 바인딩
p1.Info();
// std::vector를 rvalue로 전달
std::vector<Cat> catVec;
catVec.emplace_back("Bengal", 2018);
catVec.emplace_back("Maine Coon", 2016);
VectorFunction(std::move(catVec)); // std::move로 rvalue로 전달
}
다음의 Cat&& p1 = Cat("Persian cat", 2010);
이 부분을 보면 임시 객체를 그대로 바로 rvalue 참조에 직접 넘겨주고 있는 것을 알 수 있습니다.
VectorFunction(std::move(catVec));
같은 경우도 rvalue로 vector&& param에 catVec을 전달하고 있습니다. 위 코드의 실행 결과는 다음과 같습니다.
#include <iostream>
#include <string>
#include <utility> // std::move, std::forward
class Cat {
public:
std::string name;
Cat(const std::string& n) : name(n) {
std::cout << "Constructor: " << name << std::endl;
}
// Copy Constructor
Cat(const Cat& other) : name(other.name) {
std::cout << "Copy Constructor for " << name << std::endl;
}
// Move Constructor
Cat(Cat&& other) noexcept : name(std::move(other.name)) {
std::cout << "Move Constructor for " << name << std::endl;
}
void Info() const {
std::cout << "Cat: " << name << std::endl;
}
};
// 이 함수는 오직 rvalue만 받습니다
void AcceptRvalue(Cat&& c) {
std::cout << "[AcceptRvalue] ";
c.Info();
}
// 이 함수는 오직 lvalue만 받습니다
void AcceptLvalue(const Cat& c) {
std::cout << "[AcceptLvalue] ";
c.Info();
}
// universal reference + std::forward
template<typename T>
void UniversalFunction(T&& param) {
std::cout << "[UniversalFunction] forwarding to the correct function...\n";
AcceptOverload(std::forward<T>(param));
}
// 내부에서 lvalue/rvalue에 따라 호출될 함수
void AcceptOverload(Cat&& c) {
AcceptRvalue(std::move(c));
}
void AcceptOverload(const Cat& c) {
AcceptLvalue(c);
}
int main() {
Cat kitty("Kitty");
std::cout << "\n>> Call with lvalue\n";
UniversalFunction(kitty); // lvalue → lvalue로 전달
std::cout << "\n>> Call with rvalue\n";
UniversalFunction(Cat("Tom")); // rvalue → rvalue로 전달
return 0;
}
위의 코드를 보면 std::move를 사용하여 lvalue로 전달받은 매개변수는 그대로 lvalue로 사용하고 rvalue로 전달받은 매개변수는 rvalue로 사용하고 있습니다. template 함수를 자세히 보면 AcceptOverload에 T 타입을 사용하는 매개변수를 전달하는데 문제 없이 컴파일이 됩니다. 실행 결과는 다음과 같습니다.
언리얼 엔진에서는 UnrealTemplate.h 파일을 참고해보면 다음과 같이 선언되어 있습니다. std::forward를 언리얼 엔진에서 비슷하게 만들었다고 주석에 나와있습니다. 참고로 std::remove_reference_t는 type으로부터 non reference type을 만들어 냅니다.
// output : remove_reference_t<int&> == int
#include <type_traits>
#include <iostream>
int main()
{
int *p = (std::remove_reference_t<int&> *)0;
p = p; // to quiet "unused" warning
std::cout << "remove_reference_t<int&> == "
<< typeid(*p).name() << std::endl;
return (0);
}
위의 코드에서 (std::remove_reference_t<int&> *)0;
가 생소할 수 있는데요. 0, 즉 널포인터를 std::remove_reference_t<int&>
타입의 포인터로 캐스팅하는 것입니다. 즉 int&
에서 reference를 제거했으니 int
가 되고 여기에 *
를 붙였으니 int*
가 됩니다. 이것만 이해했다면 잘 다룰 수 있습니다.
/**
* Forward will cast a reference to an rvalue reference.
* This is UE's equivalent of std::forward.
*/
template <typename T>
UE_INTRINSIC_CAST FORCEINLINE constexpr T&& Forward(std::remove_reference_t<T>& Obj) noexcept
{
return (T&&)Obj;
}
template <typename T>
UE_INTRINSIC_CAST FORCEINLINE constexpr T&& Forward(std::remove_reference_t<T>&& Obj) noexcept
{
return (T&&)Obj;
}
즉 언리얼 엔진에서의 Forward는 T 타입의 매개변수에 reference를 제거하고 T타입의 Rvalue로 캐스팅하여 리턴합니다. std::forward과 같습니다.
💡 이제 언리얼 엔진에서 제가 사용했던 코드를 보여드리겠습니다. 다음처럼 hud에서 ui 메시지를 띄우는 기능인데요. 상당히 많은 부분에 같은 로직이 사용되고 있었습니다. 이를 template 함수로 사용하기 편하게 만들고자 했습니다.
if (AMyHUD* myHUD = Cast<AMyHUD>(GetHUD()))
{
if (myHUD->m_wHUDPanelWidget)
{
myHUD->m_wHUDPanelWidget->PopUp(FText::FromString(FString::Printf(TEXT("Remain Time : %.0f"), _time)));
}
}
if (AMyHUD* myHUD = Cast<AMyHUD>(GetHUD()))
{
if (myHUD->m_wHUDPanelWidget)
{
myHUD->m_wHUDPanelWidget->PopUp(FText::FromString(FString::Printf(TEXT("Move Next Level..."))));
}
}
if (AMyHUD* myHUD = Cast<AMyHUD>(GetHUD()))
{
if (myHUD->m_wHUDPanelWidget)
{
myHUD->m_wHUDPanelWidget->PopUp(FText::FromString(FString::Printf(TEXT("You Attacked! %s %f"), _other, _damage)));
}
}
구조를 잘 보면 FText로 전달하는 저 부분만 바뀌고 있습니다. 따라서 FmtType으로 rvalue를 받고 이 rvalue를 Forward 함수를 사용해 그대로 PopUp함수에 rvalue를 전달하게끔 만들었습니다. 물론 매개변수를 여러 개 받을 수 있도록 typename...도 사용했습니다.
template<typename FmtType, typename... Args>
void PopUp(FmtType&& Format, Args&&... args)
{
const FString Text = FString::Printf(Forward<FmtType>(Format), Forward<Args>(args)...);
if (AMyHUD* myHUD = Cast<AMyHUD>(GetHUD()))
{
if (myHUD->m_wHUDPanelWidget)
{
myHUD->m_wHUDPanelWidget->PopUp(FText::FromString(Text));
}
}
}
이제 다음처럼 간단하게 사용할 수 있습니다.
PopUp(TEXT("Remain Time : %.0f"), _time);
PopUp(TEXT("Move Next Level..."), _time);
PopUp(TEXT("You Attacked! %s %f"), _other, _damage);