Callable

Callable이란, 말 그대로 호출이 가능한 모든 것을 가리킨다. 호출이 가능한게, 함수 말고 또 뭐가 있겠나 싶을 수도 있지만, 함수 객체, 람다 함수 등이 대표적인 Callable이다.

 

함수 객체

함수 객체란, 객체를 함수처럼 사용하는 것이다.

코드를 보면 단 번에 이해가 될 것이다.

class FuncObject
{
public:
    int operator()(int _A, int _B)
    {
        return (_A + _B);
    }
};

위의 클래스를 보자. 클래스 내에 ()연산자를 오버로딩하여 두 수의 합을 반환하도록 하였다.

해당 객체는 아래와 같이 사용될 수 있다.

int main()
{
    FuncObject NewFuncObject;
    int Sum = NewFuncObject(5, 6);
    
    return 0;
}

 NewFuncObject는 함수가 아니라, 객체의 인스턴스이다. 그럼에도, () 연산자를 오버로딩 했기 때문에 함수와 동일하게 사용될 수 있다. 이러한 것을 함수 객체라고 한다. 실제로 함수는 아니고 객체이지만, 함수처럼 사용되는 것이다.

 

그냥 함수를 쓰면 되는데 왜 이런 걸 쓰냐? 라는 생각이 들 수도 있다. 

함수 객체의 장점은 속성을 저장하는 등, 함수에선 사용할 수 없는 기능을 추가로 사용할 수 있다는 장점이 있다.

 

아래의 코드를 보자.

class Plus
{
    int Count = 0;
    
public:
    void operator=()(int& _A)
    {
        Count++;
        _A += Count;
    }
};

특정 변수에 대한 참조형을 인수로 넘기면, 값을 증가시키는 함수를 만들었다고 해보자.

이 함수가 여러 번 실행될 때마다 더해지는 값이 1씩 증가하도록 만들고 싶다.

 

예를 들면, 처음엔 1을 더하고 두 번째 호출되었을 때는 2를 더하고 세 번째 호출되었을 때는 3을 더하는 것이다.

게임에서 이런 경우를 자주 볼 수 있을 것이다. 실행 횟수가 늘어날수록 그 비용이 증가하는 것이다.

 

일반적인 함수를 사용해서 이를 구현하기 위해선, 별도로 함수가 호출된 횟수를 저장하고 기록해야 한다. 하지만, 함수 객체를 사용하는 경우 객체 내부에 횟수를 멤버변수로 저장하여 사용할 수 있기 때문에 더욱 간단하게 구현 및 사용이 가능한 것이다.

 

위의 코드를 보면, 객체 내부에 Count라는 변수를 두고, ()연산자가 호출될 때마다 이 변수의 값을 증가시키고 있다.

이처럼, 함수 객체는 객체 내부에 별도의 속성을 정의하여 다양하게 활용할 수 있다는 장점이 있다.

 

또한, 속도가 일반 함수보다 빠르다고 한다. 얼마나 차이나는지는 정확히 모르겠지만, 함수를 사용할 땐 inline 처리가 되지 않지만 함수 객체를 사용하면 inline으로 처리가 가능한 부분이 있다고 한다.

 

람다 함수

람다 함수란, 이름이 없는 함수를 말한다. 조금 더 실전적으로 말하자면, 미리 정의해놓은 함수를 사용하는 것이 아니라 필요할 때마다 즉석에서 정의하는 것이다.

 

아래 코드를 보자.

int main()
{
    []()->int{return 10;};
}

 뭔가 요상한 식이 적혀있는 것을 볼 수 있다.

이 것은 람다 함수로, 상수처럼 일회성으로 사용되는 함수이다. (물론 지속적으로 사용하는 방법도 있다. 이는 std::function과 함께 설명하도록 하겠다.)

 

먼저, 구문을 분석해보자. 

 

앞의 []는 람다 캡쳐라는 것이다. 람다 캡쳐는 조금 뒤에 설명하도록 하겠다.

()는 함수에 사용할 파라미터이다.

->int 는 함수의 반환형을 의미한다. ->char을 사용하면 반환형이 char이 되고, ->std::string 을 사용하면 반환형이 std::string이 된다.

 

여기서 ->int 부분은 생략해도 함수는 정상 작동한다. 이는 반환형을 명시하기 위한 것이고, 실제로는 컴파일러가 알아서 처리해준다.

 

뒤의 중괄호가 함수의 구현부이다. 그 안에 함수를 구현하는 대로 람다함수가 작동하는 것이다.

//람다 표현식
[](int _A, int _B)->int{return _A + _B;};

//일반 함수
int Sum(int _A, int _B)
{
    return _A + _B;
}

위의 코드에서 람다 표현식으로 작성한 것과 일반 함수로 작성한 것은 동일하게 기능한다.

람다 함수를 저렇게 구현했다면, 사용은 어떻게 할까?

[](int _A, int _B)->int{return _A + _B;}(3, 5);

일반 함수들과 동일하게 뒤에, ()을 붙이고 인자를 넣으면 된다.

그런데, 우리는 람다 함수 구현부에서 외부의 변수를 사용하고 싶을 수도 있다.

 

외부의 변수란, 말 그대로 람다 함수 구현부 내부에서 선언된 변수가 아니라 외부에서 선언된 변수를 의미한다.

아래의 코드를 보자.

int main()
{
    int A = 0;
    int B = 0;
    
    []()->int
    {
    	return A + B;
    };
    
    return 0;
}

이렇게 람다함수를 구현했다고 해보자.

 

함수 내부에서는 A와 B를 사용하고 있지만, 실제로 함수 내부에서 선언된 것은 아니다.

함수 외부에 있는 변수를 저렇게 그냥 사용하게 되면 컴파일 오류가 발생한다.

 

이러한 문제를 해결하기 위해 사용하는 것이 앞서 말한 람다 캡쳐이다.

람다 캡쳐는 람다 함수 선언 코드의 가장 앞에 존재하는 [] 이다.

[] 괄호 사이에는 몇 가지 기호를 삽입할 수 있다.

 

[=], [&], [변수 이름] 등으로 사용할 수 있다.

 

[=] : 외부에 있는 변수를 Call By Value로 사용하겠다는 의미이다.

당연히, 람다 함수가 선언된 지역에서 참조할 수 있는 값만 사용이 가능하다.

 

[&] : 외부에 있는 변수를 Call By Reference로 사용하겠다는 의미이다.

역시나, 람다 함수가 선언된 지역 내에서 참조 가능한 변수만 사용이 가능하다.

 

[A] : A는 위의 코드에서 선언된 변수의 이름이다. [=]은 외부의 모든 값에 대해 사용을 허락하지만, 이렇게 변수 이름을 직접 삽입하게 되면 해당 변수만 사용이 가능하다.

 

[&A] : 이렇게 참조형으로도 사용이 가능하다.  

 

[A, &B] : 이렇게 특정 변수는 Value로 특정 변수는 Reference로 사용하는 것도 가능하다.

 

[] : 만약 이렇게 아무 것도 삽입하지 않으면, 람다 함수 내부에서 선언된 값이나 전역으로 선언된 값만 사용이 가능하다.

 

int main()
{
    int A = 0;
    int B = 0;
    
    [=]()->int
    {
    	return A + B;
    };
    
    return 0;
}

그러므로, 위의 코드는 이렇게 람다 캡쳐에 = 을 넣어 주어야 컴파일 오류가 발생하지 않는다.

 

int main()
{
    int A = 0;
    int B = 0;
    
    int Sum = [=]()->int
    {
    	return A + B;
    }();
    
    return 0;
}

이렇게 반환 값을 변수에 저장할 수도 있다.

 

std::function

그런데, 람다 함수는 사실 저렇게만 사용하면 매우 불편한 함수이다.

가독성도 떨어질 뿐더러, 함수의 재사용성도 개나 줘버린 상태인 것이다. 

 

std::function과 함께 사용하면, 람다 함수의 효율성을 더욱 높일 수 있다.

허나, 그건 부가적으로 따라오는 효과일 뿐 std::function의 목적은 아니다.

 

std::function은 C스타일의 함수 포인터가 가지고 있던 단점들을 보완하기 위해, C++에서 지원하는 컨테이너이다.

std::function은 std::function<반환형(파라미터)> 변수 이름 의 형태로 선언하여 사용할 수 있다.

class FuncObject
{
public:
    int operator()(int _A, int _B)
    {
        return (_A + _B);
    }
};

int main()
{
    FuncObject NewFuncObject;

    std::function<int(int, int)> Functor = NewFuncObject;
    int Sum = Functor(3, 5);
}

이렇게, std::function을 사용하면 함수 객체를 저장하여 사용할 수 있다.

int main()
{
    std::function<int()> Lamda = []()->int{return 10;};
    int LamdaReturn = Lamda();
}

이렇게 람다 함수를 저장하여, 간편하게 사용하는 것도 가능하다.

람다함수를 이렇게 std::function 과 함께 사용하면 여러모로 편리한 점이 많다.

 

멤버함수 또한, std::function에 저장할 수 있다.

class Object
{
public:
    void Test()
    {
        //
    }
};

int main()
{
    std::function<void(Object*)> MemberFunc = &Object::Test;
    //std::function<void(Object&)> MemberFunc = &Object::Test;
}

이렇게, 멤버 함수를 저장할 수도 있다. 중요한 점은 인자로 클래스의 참조형을 받고 있다는 것이다. (포인터가 아닌 참조자도 가능하다.) 멤버 함수는 반드시 그 함수를 실행할 객체의 주소가 필요하기 때문에, 첫 번째 인자로 반드시 객체의 주소를 넘겨주어야 한다.

 

사용할 떄 역시, 객체의 주소를 인자로 넘겨주어야 한다.

class Object
{
public:
    void Test()
    {
        //
    }
};

int main()
{
    std::function<void(Object&)> MemberFunc = &Object::Test;
    //std::function<void(Object*)> MemberFunc = &Object::Test;
    
    Object NewObject;
    MemberFunc(&NewObject);
}

위처럼 &NewObejct를 인자로 넘겨주어야 한다.

 

std::bind

하지만, 매 번 객체의 주소를 넘겨주는 것이 상당히 불편한 일이다.

다행히도 C++에선, 멤버 함수 포인터와 객체의 주소를  하나로 묶어버리는 std::bind라는 기능을 지원해준다.

 

std::bind는 std::function에 멤버 함수 포인터를 저장할 때 객체의 주소와 함수를 묶은 상태로 std::function에 저장해준다.

std::function에 저장된 함수를 사용할 때, 객체의 주소값을 대입하는 것 없이 그냥 호출할 수 있게 된다.

int main()
{
    Object NewObject;
    
    std::function<void()> BindedMemberFunc = std::bind(&Object::Test, &NewObject);
    BindedMemberFunc();
}

이렇게, std::bind의 첫 번째 인자에 함수의 주소값을 넣고, 두 번째 인자로 객체를 넣어주면 된다.

위 코드와 같이 함수를 실행할 때, 객체의 주소를 인자로 사용하지 않아도 정상작동하게 된다.

 

std::bind를 엄밀히 설명하자면, 객체를 함수에 묶어준다기보다는 인자를 묶어주는 개념이다. 위의 코드에선, 멤버함수의 첫 번째 인자가 반드시 객체의 포인터임을 이용해서 객체의 주소를 함수와 묶어준 것이다.

 

그렇기 때문에, 객체의 주소가 아니라도 그냥 인자를 고정하여 묶어줄 수 있게 된다. 예를 들어, 플레이어 객체가 소유하고 있는 장비 아이템의 개수와 소비 아이템의 개수를 반환하는 함수를 만든다고 해보자.

class Player
{
public:
    int NumEquip = 0;
    int NumConsumption = 0;
};

int SumFunc(const int* _A, const int* _B)
{
    return *_A + *_B;
}

int main()
{
    Player NewPlayer;
    std::function<int()> SumFuncPtr = std::bind(&SumFunc, &NewPlayer.NumEquip, &NewPlayer.NumConsumption);
    std::cout << SumFuncPtr() << std::endl;
}

std::bind의 두 번째 인자와 세 번째 인자로 멤버함수의 변수 NumEquip과 NumConsumption 대입하였다.

이후, SumFuncPtr을 호출할 때특별한 인자를 삽입하지 않아도 두 변수의 합을 반환해주게 된다.

 

이처럼, 인자를 함수에 묶어주는 역할을 하는 것이 std::bind이다. 객체의 주소가 아니더라도 파라미터와 일치하는 값은 다 넣어줄 수 있다. 하지만, 두 개의 인자 중 하나만 묶어놓고 나머지 인자는 상황에 맞게 사용하고 싶을 수도 있다.

 

예를 들어, 플레이어가 가지고 있는 소비 아이템에서 일정 개수를 삭제하는 코드를 구성한다고 해보자.

class Player
{
public:
    int NumEquip = 0;
    int NumConsumption = 0;
};

void RemoveFunc(int* _Target, int _RemoveValue)
{
    *_Target -= _RemoveValue;
}

int main()
{
    Player NewPlayer;
    std::function<void(int)> RemoveFuncPtr = std::bind(&RemoveFunc, &NewPlayer.NumConsumption, std::placeholders::_1);
    RemoveFuncPtr(5);
}

위의 코드를 보면, 두 개의 인자중 하나는 NewPlayer.NumCumption을 넣어주었지만, 두 번째 인자는 std::placeholders::_1 이라는 인자를 대입하였다. 그리고 함수를 실제로 호출할 때엔, int형 정수값 하나만 인자에 대입해주었다.

 

std::placeholders는 임시로 인자를 묶어주는 역할이다. 함수는 인자를 두 개를 받지만, 하나만 묶고 싶을 때 남은 자리를 채워주는 역할이다.

 

std::placeholder뒤의 숫자 _1은 인자의 순서이다. _1, _2, _3 등등 여러 숫자를 사용할 수 있다.

이 때, 만약 두 인자를 std::placeholder::_1, std::placeholder::_2 가 아니라, std::placeholder::_1, std::placeholder::_1 과 같이 숫자를 중복해서 넣게 되면, 인자로 (3, 6) 이렇게 2개를 넣어도 실제로는 함수에 (3, 3) 의 인자가 전달된다.

두 번째 인자의 값에도 첫 번째 인자의 값이 강제로 들어가버리는 것이다. 그러므로, 순서에 주의하여 사용하여야 한다.

+ Recent posts