본문 바로가기
Unreal 게임 개발/Unreal C++ 공부

C++ 람다 표현식 (Lambda expression)

by daisy0461 2024. 9. 5.
auto basicLambda = [] (int i) {cout << "test: " << i << endl; };
basicLambda(365);

C++ 코드를 보니 람다 표현식이 많이 나오는데 정확하게 무엇인지 몰라서 공부하게 되었다.

 

람다 표현식

함수나 객체를 별도로 정의하지 않고 필요한 시점에서 바로 함수를 만들어 쓸 수 있는 일종의 익명함수이다.

잘 사용하면 코드를 깔끔하게 만들 수 있다.

 

문법

기본적인 문법

auto basicLambda = [] {cout << "test" << endl; };
basicLambda();

람다 표현식은 람다 선언자(람다 소개자)라는 []로 시작하고 람다 표현식의 본문을 담는 {}가 나온다.

람다 표현식은 auto 타입 변수인 basicLambda에 대입된다.

 

auto basicLambda = [] (int i) {cout << "test: " << i << endl; };
basicLambda(365);

람다 표현식도 일반 함수와 마찬가지로 ()안에 매개변수를 넣을 수 있다.

람다 표현식에서 매개변수를 받지 않을 땐 빈 ()를 넣거나 생략하면 된다.

 

auto basicLambda = [](int a, int b) -> int {return a + b; };
basicLambda(365, 100);

람다 표현식도 값을 return할 수 있다.

return 타입은 -> 뒤에 지정한다. 이런 표기법을 후행(후위) 리턴타입(trailing return type)이라 부른다.

auto basicLambda = [](int a, int b) {return a + b; };
int result = basicLambda(365, 100);

return 타입은 다음과 같이 생략해도 괜찮다. return 타입 추론 규칙에 따라 람다 표현식의 리턴 타입을 추론한다.

auto basicLambda = [](auto a, auto b) {return a + b; };
int result = basicLambda(365, 100);

물론 int a, int b 대신에 auto를 사용해도 무관하다.

 

 

람다 캡쳐

double data = 1.23;
auto capturingLambda = [data] {cout << "Data : " << data << endl; };

[]부분을 람다 캡쳐 블록(capture block)이라고 한다.

어떤 변수를 []안에 지정해서 람다 표현식의 본문 안에 해당 변수를 사용가능하도록한다. 이를 캡쳐라고 한다.

[]안에 복제된 data는 캡쳐한 변수의 const 속성을 그대로 이어받는다.

 

double data = 1.23;
auto capturingLambda = [data]() mutable {data *= 2;  cout << "Data : " << data << endl; };

컴파일러는 람다 표현식을 이름 없는 펑터(함수 객체)로 변환한다.

펑터마다 operator()가 구현돼 있고 람다 표현식은 기본적으로 const로 설정된다.

따라서 []안에 non-const 변수를 캡쳐해도 복제본을 수정할 수 없다.

이를 수정하기 위해서 mutable을 사용한다. mutable을 사용하기 위해선 매개변수가 없어도 ()를 적어야한다.

 

double data = 1.23;
auto capturingLambda = [&data] {data *= 2; };

변수 이름 안에 &붙이면 레퍼런스로 캡쳐한다. 위 코드는 data를 직접 수정하는 코드이다.

 

double data = 1.23;
auto capturingLambda = [myCapture = "Data : ", &data] {cout << myCapture << data << endl; };
capturingLambda();

위 방법은 람다 캡쳐 표현식(lambda capture expression)이며 표현식에 사용할 캡쳐 변수를 초기화한다.

 

캡쳐리스트

변수 이름 앞에 & or =를 붙이려면 람다 표현식 내에서 초기화하는 변수가 없다면 반드시 캡쳐 리스트의 첫번째 원소는 반드시 & or =로 표시해줘야한다.

[=]: 스코프에 있는 변수를 모두 값으로 캡쳐한다.

[&]: 스코프에 있는 변수를 모두 레퍼런스로 캡쳐한다.

[=, &x, &y]: x, y는 레퍼런스로 캡쳐하고 나머지는 값으로 캡쳐한다.

 

 

람다 표현식을 리턴 타입으로 사용

function<int(void)> f1(int x)
{
	return [x] {return x * 2; };
}

std:function을 이용하면 함수가 람다 표현식을 리턴하게 할 수 있다.

여기서 주의할 점이 있는데

function<int(void)> f1(int x)
{
	return [&x] {return x * 2; };
}

위 코드와 같이 사용하면 x를 레퍼런스로 캡쳐하고 있다.

리턴한 람다 표현식은 대부분 함수가 끝난 뒤 사용된다. 그러니 함수가 끝났으면 f1()함수의 스코프는 더 이상 존재하지 않아서 &x는 이상한 값을 가리키게 된다.

 

------------------------------------------------------------------------------------------------

위에는 람다 함수의 기본 설명이고 이제 궁금한 점이 두가지가 남았다.

이게 왜 코드를 깔끔하게 만드는지? Unreal에서는 어떤 식으로 사용되는지?

 

왜 깔끔하다고 하는가?

#include <iostream>
#include <vector>
#include <algorithm>

// 제곱을 계산하는 별도의 함수 정의
int square(int x) {
    return x * x;
}

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};

    // std::transform을 사용해 모든 요소를 제곱
    std::vector<int> squared_numbers(numbers.size());
    std::transform(numbers.begin(), numbers.end(), squared_numbers.begin(), square);

    // 결과 출력
    for (int n : squared_numbers) {
        std::cout << n << " ";
    }
    return 0;
}

위 코드에선 square(int x)함수를 따로 만들어서 main에서 사용했다.

물론 square(int x)함수를 자주 사용한다면 옳은 선택이다.

하지만 1번만 사용한다면 람다 함수가 낫다.

 

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> numbers = {1, 2, 3, 4, 5};

    // std::transform과 람다 함수를 사용해 모든 요소를 제곱
    std::vector<int> squared_numbers(numbers.size());
    std::transform(numbers.begin(), numbers.end(), squared_numbers.begin(), [](int x) {
        return x * x;
    });

    // 결과 출력
    for (int n : squared_numbers) {
        std::cout << n << " ";
    }
    return 0;
}

위 코드를 보면 square(int x)에 대한 정의가 없다.

대신에 transform안에 람다 함수를 넣어서 사용하고 있다.

이렇게 되면 함수를 정의할 필요가 없어서 간결해질 뿐만 아니라 변환 작업이 어디서 일어나는지 바로 확인이 가능하다.

또한 해당 부분만 변경하고 싶을 때 빠르게 변경이 가능하다. 만약 함수로 정의한다면 다른 곳에 해당 함수를 사용했다면 다 똑같이 변형되기 때문이다.

 

Unreal에선 어떻게 사용하는가?

일반적으로 위처럼 사용하는 것 말고 Behavior Tree의 Task를 제작할 때 사용한 예시이다.

EBTNodeResult::Type UBTTask_DefaultAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
    EBTNodeResult::Type Result = Super::ExecuteTask(OwnerComp, NodeMemory);
    
    APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
    if(nullptr == ControllingPawn){
        UE_LOG(LogTemp, Display, TEXT("BTTask_DefaultAttack Can't find Pawn"));
        return EBTNodeResult::Failed;
    }

    AEnemy* OwnerEnemy =  Cast<AEnemy>(ControllingPawn);
    if(nullptr == OwnerEnemy){
        UE_LOG(LogTemp, Display, TEXT("BTTask_DefaultAttack Cast Failed"));
        return EBTNodeResult::Failed;
    }

    FAICharacterAttackFinished OnAttackFinished;
    OnAttackFinished.BindLambda(
        [&]()
        {
            UE_LOG(LogTemp, Display, TEXT("Attack Succeeded"));
            FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
        }
    );

    OwnerEnemy->SetAIAttackDelegate(OnAttackFinished);
    OwnerEnemy->AttackByAI();
    return EBTNodeResult::InProgress;
}

OnAttackFinished.BindLambda를 통해 람다함수로 Bind하는 것을 확인할 수 있다.

 

그렇다면 사용하지 않으면 어떻게 될까?

EBTNodeResult::Type UBTTask_DefaultAttack::ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
    BehaviorTreeComponent = &OwnerComp; // Save reference to BehaviorTreeComponent

    APawn* ControllingPawn = OwnerComp.GetAIOwner()->GetPawn();
    if (nullptr == ControllingPawn)
    {
        UE_LOG(LogTemp, Display, TEXT("BTTask_DefaultAttack Can't find Pawn"));
        return EBTNodeResult::Failed;
    }

    AEnemy* OwnerEnemy = Cast<AEnemy>(ControllingPawn);
    if (nullptr == OwnerEnemy)
    {
        UE_LOG(LogTemp, Display, TEXT("BTTask_DefaultAttack Cast Failed"));
        return EBTNodeResult::Failed;
    }

    // Bind the handler
    FAICharacterAttackFinished OnAttackFinished;
    OnAttackFinished.BindUObject(OwnerEnemy, &AEnemy::OnAttackFinishedHandler);

    OwnerEnemy->SetAIAttackDelegate(OnAttackFinished);
    OwnerEnemy->AttackByAI();

    // Task is in progress until the attack is done
    return EBTNodeResult::InProgress;
}

void UBTTask_DefaultAttack::FinishTask(EBTNodeResult::Type TaskResult)
{
    if (BehaviorTreeComponent)
    {
        FinishLatentTask(*BehaviorTreeComponent, TaskResult);
    }
}

여기만 보면 더 나아보인다.
OnAttackFinished.BindUObject를 통해서 함수를 Bind한다.

하지만 이렇게 만들기 위해서 Enemy에 OnAttackFinishedHandler를 만들어야한다.

void AEnemy::OnAttackFinishedHandler()
{
    UE_LOG(LogTemp, Display, TEXT("Attack Succeeded"));
    // `BehaviorTreeComponent`를 통해 `FinishLatentTask` 호출
    if (BehaviorTreeComponent)
    {
        FinishLatentTask(*BehaviorTreeComponent, EBTNodeResult::Succeeded);
    }
}

해당 기능을 자주 사용할거라면 관계없지만 Attack관련 Task에서만 해당 함수를 사용할 예정이기에 람다를 채용해서 사용했다.

'Unreal 게임 개발 > Unreal C++ 공부' 카테고리의 다른 글

Unreal StaticClass()  (0) 2024.10.03
Unreal Module 추가 방법  (0) 2023.01.26
Unreal Axis Mapping (Unreal 움직임 만들기)  (0) 2023.01.22
Unreal Default Subobject  (0) 2023.01.18
Unreal Class Default Object  (0) 2023.01.17