References
- Professional C++
- https://en.cppreference.com/w/
Contents
- Function Pointer (함수 포인터)
- Pointers to Methods (or Data Members)
C++의 함수는 다른 함수에 인수로 전달하거나, 다른 함수로부터 리턴되거나 변수에 할당하는 등 일반 변수와 같은 방식으로 사용될 수 있기 때문에 first-class function(일급 함수)라고 불립니다. 이런 문맥에서 자주 등장하는 용어는 호출할 수 있는 것이라고 표현되는 콜백(callback) 입니다. 여기서 콜백이란 함수 포인터(function pointer)나 함수 포인터와 슈사한 역할을 하는 것들을 말하는데, 예를 들면 operator()를 오버로딩한 객체와 인라인 람다 표현식(inline lambda expression)이 있습니다. 그중에서 operator()를 오버로딩한 것을 함수 객체(function object) 또는 펑터(functor)라고 부릅니다.
1. Function Pointers
함수도 내부적으로 특정한 메모리 주소가 지정됩니다. 물론 함수의 메모리 위치를 신경 쓸 일은 많지 않습니다. 하지만 C++에서는 함수를 데이터로 취급할 수 있습니다. 다시 말하면, 함수의 주소를 변수 다루듯 사용할 수 있다는 것입니다.
함수 포인터의 타입은 매개변수 타입과 리턴 타입에 따라 결정됩니다. 함수 포인터를 다루는 방법 중 하나는 타입 앨리어스(type alias)를 사용하는 것입니다. 타입 앨리어스를 사용하면 특정한 속성을 가진 함수들을 타입 이름 하나로 부를 수 있습니다. 예를 들어, int 매개변수 두 개와 bool 리턴값 하나를 가진 함수의 타입을 MatchFunction이란 이름으로 정의하려면 다음과 같이 작성합니다.
using MatchFunction = bool(*)(int, int);
이렇게 타입을 정의하면, 이제 MatchFunction라는 콜백을 파라미터로 받는 함수를 작성할 수 있습니다. 이렇게 다른 함수를 파라미터로 받거나, 다른 함수를 리턴하는 함수를 higher-order functions(고차 함수)라고 부릅니다.
예를 들어, 아래 코드의 함수는 int 배열 두 개와 각각의 크기, MatchFunction을 파라미터로 받습니다. 그리고 전달받은 두 배열에 대해 루프를 돌면서 각 원소를 MatchFunction의 인수로 전달하고 호출해서 true라는 결과가 나오면 특정한 메세지를 화면에 출력합니다. 여기서 MatchFunction의 구현은 신경쓰지 않고, MatchFunction을 변수처럼 전달하더라도 여전히 일반 함수처럼 호출할 수 있다는 것입니다.
void findMatches(int values1[], int values2[], size_t numValues,
MatchFunction matcher)
{
for (size_t i = 0; i < numValues; i++) {
if (matcher(values1[i], values2[i])) {
std::cout << "Match found at position " << i <<
" (" << values1[i] << ", " << values2[i] << ")" << std::endl;
}
}
}
이렇게 구현하려면 두 배열은 모두 최소한 numValues개의 원소를 가져야 합니다. 그리고 타입이 MatchFunction과 일치하는 함수라면 어떤 것도 findMatches() 함수의 인자로 전달할 수 있습니다. 다시 말해 int 값 두 개를 파라미터로 받고 bool값을 리턴하는 함수면 모두 인수로 지정할 수 있습니다.
예를 들어 다음과 같이 두 매개변수의 값이 같으면 true를 리턴하도록 정의한 함수가 있다고 합시다.
bool intEqual(int item1, int item2) { return item1 == item2; }
intEqual() 함수는 MatchFunction 타입과 일치하기 때문에 다음과 같이 findMatches()의 마지막 인수로 전달할 수 있습니다.
int arr1[] = { 2, 5, 6, 9, 10, 1, 1 };
int arr2[] = { 4, 4, 2, 9, 0, 3, 4 };
size_t arrSize = std::size(arr1);
std::cout << "Calling findMatches() using intEqual():" << std::endl;
findMatches(arr1, arr2, arrSize, &intEqual);
여기서는 intEqual() 함수를 findMatches() 함수에 포인터로 전달했습니다. 정확한 문법에 따라 여기 나온 것처럼 &를 붙여야 하지만 &를 생략하고 함수 이름만 적어도 컴파일러는 이 값이 주소라고 판단합니다. 따라서 아래 코드를 실행하면 다음과 같이 같은 결과를 볼 수 있습니다.
이 예제에서는 findMatches()가 두 배열의 값을 동시에 비교하는 제너릭 함수이기 때문에 함수 포인터를 사용하면 유리합니다. 방금 전의 코드는 두 값이 같은지 비교하는 데만 사용했지만, 인수를 함수 포인터로 전달하기 때문에 얼마든지 다른 기준으로 비교하도록 지정할 수 있습니다.
예를 들어, 다음 함수도 MatchFunction에 해당합니다.
bool bothOdd(int item1, int item2) { return item1 % 2 == 1 && item2 % 2 == 1; }
그리고 다음과 같이 bothOdd를 findMatches()의 인수로 전달할 수 있습니다.
std::cout << "Calling findMatches() using bothOdd():" << std::endl;
findMatches(arr1, arr2, arrSize, bothOdd);
이처럼 함수 포인터를 사용하면 findMatches()란 함수를 matcher란 매개변수의 값에 따라 다양한 용도로 커스터마이즈할 수 있습니다.
C++에서는 함수 포인터를 잘 사용하지는 않지만 간혹 함수 포인터가 필요할 때가 있습니다. 대표적인 예로 동적 링크 라이브러리(Dynamic Linked Library, DLL)에 있는 함수에 대한 포인터를 구할 때입니다.
아래에서 살펴볼 코드는 MS 윈도우 DLL에 있는 함수의 포인터를 구하는 예를 보여줍니다.
hardware.dll이란 DLL에 Connect()란 함수가 있다고 해봅시다. 이 Connect()를 호출하려면 먼저 DLL을 불러와야 합니다. 런타임에 DLL을 불러오는 작업은 다음과 같이 윈도우에서 제공하는 LoadLibrary() 함수를 사용합니다. (<windows.h>를 include하면 사용할 수 있습니다.)
HMODULE lib = ::LoadLibrary("hardware.dll");
이렇게 호출된 결과를 라이브러리 핸들(library handle)이라고 부릅니다. 이 과정에서 에러가 발생하면 NULL이 리턴됩니다. 라이브러리로부터 함수를 불러오려면, 그 함수의 프로토타입을 알아야 합니다. Connect()의 프로토타입이 다음과 같이 int 값 하나를 리턴하고, 매개변수를 3개(bool, int, C-Style string) 받는다고 가정해보겠습니다.
int __stdcall Connect(bool b, int n, const char* p);
여기서 __stdcall은 마이크로소프트에서 정의한 지시자로서 함수에 매개변수가 전달되는 방식과 메모리를 해제하는 방식을 지정합니다.
이제 타입 앨리어스를 이용해서 앞에 나온 프로토타입을 가진 함수에 대한 포인터에 이름(ConnectFunction)을 정의합니다.
using ConnectFunction = int(__stdcall*)(bool, int, const char*);
DLL을 불러오는 과정에 문제가 없고 함수 포인터도 정의했다면 DLL에 있는 함수에 대한 포인터를 다음과 같이 구할 수 있습니다.
ConnectFunction connect = (ConnectFunction)::GetProcAddress(lib, "Connect");
이 과정에서 에러가 발생하면 connect 값은 nullptr이 됩니다. 정상적으로 처리되었다면 방금 받은 함수 포인터로 다음과 같이 호출할 수 있습니다.
connect(true, 3, "Hello world");
2. Pointers to Methods (and Data Members)
방금 위에서 함수에 대해 포인터를 만들어서 사용할 수 있다는 것을 알았습니다. 물론 변수에 대한 포인터를 만들어서 사용할 수 있다는 것을 알고 있을 것입니다. 그렇다면 클래스의 데이터 멤버와 메소드에는 어떨까요?
C++은 클래스의 데이터 멤버와 메소드에 대한 주소를 가져오는 기능도 정식으로 지원합니다. 하지만 non-static 데이터 멤버나 메소드는 반드시 객체를 통해 접근해야 합니다. 클래스에서 데이터 멤버와 메소드를 정의하는 목적은 객체마다 이것들을 갖도록 하기 위해서입니다. 따라서 메소드나 데이터 멤버를 포인터로 접근하려면 반드시 해당 객체의 문맥에서 포인터를 역참조해야 합니다.
아래에 정의된 Employee 클래스를 예제 코드에 사용해서 살펴보도록 하겠습니다.
/* Employee.h */
#include <string>
class Employee
{
public:
Employee(const std::string& firstName, const std::string& lastName);
void promote(int raiseAmount = DefaultRaiseAndDemeritAmount);
void demote(int demeritAmount = DefaultRaiseAndDemeritAmount);
void hire(); // Hires or rehires the employee
void fire(); // Dismisses the employee
void display() const;// Outputs employee info to console
// Getters and setters
void setFirstName(const std::string& firstName);
const std::string& getFirstName() const;
void setLastName(const std::string& lastName);
const std::string& getLastName() const;
void setEmployeeNumber(int employeeNumber);
int getEmployeeNumber() const;
void setSalary(int newSalary);
int getSalary() const;
bool isHired() const;
private:
std::string m_firstName;
std::string m_lastName;
int m_employeeNumber{ -1 };
int m_salary{ DefaultStartingSalary };
bool m_hired{ false };
};
/* Employee.cpp */
#include <iostream>
Employee::Employee(const std::string& firstName, const std::string& lastName)
: m_firstName{ firstName }
, m_lastName{ lastName }
{
}
void Employee::promote(int raiseAmount)
{
setSalary(getSalary() + raiseAmount);
}
void Employee::demote(int demeritAmount)
{
setSalary(getSalary() - demeritAmount);
}
void Employee::hire()
{
m_hired = true;
}
void Employee::fire()
{
m_hired = false;
}
void Employee::display() const
{
std::cout << "Employee: " << getLastName() << ", " << getFirstName() << std::endl;
std::cout << "-------------------------" << std::endl;
std::cout << (isHired() ? "Current Employee" : "Former Employee") << std::endl;
std::cout << "Employee Number: " << getEmployeeNumber() << std::endl;
std::cout << "Salary: $" << getSalary() << std::endl;
std::cout << std::endl;
}
// Getters and setters
void Employee::setFirstName(const std::string& firstName)
{
m_firstName = firstName;
}
const std::string& Employee::getFirstName() const
{
return m_firstName;
}
void Employee::setLastName(const std::string& lastName)
{
m_lastName = lastName;
}
const std::string& Employee::getLastName() const
{
return m_lastName;
}
void Employee::setEmployeeNumber(int employeeNumber)
{
m_employeeNumber = employeeNumber;
}
int Employee::getEmployeeNumber() const
{
return m_employeeNumber;
}
void Employee::setSalary(int salary)
{
m_salary = salary;
}
int Employee::getSalary() const
{
return m_salary;
}
bool Employee::isHired() const
{
return m_hired;
}
int (Employee::*methodPtr) () const { &Employee::getSalary };
Employee employee { "John", "Doe" };
std::cout << (employee.*methodPtr)() << std::endl;
문법이 조금 복잡하긴 합니다. 첫 번째 줄은 methodPtr이란 변수를 선언하는데, 이 변수의 타입은 Employee에 있는 non-static const 메소드를 가리키는 포인터입니다. 이 메소드는 인수를 받지 않고 int 값을 리턴합니다. 여기서 선언과 동시에 변수의 값을 Employee 클래스의 getSalary() 메소드에 대한 포인터로 초기화했습니다. 이는 *methodPtr 앞에 Employee::가 붙은 점만 빼면 함수 포인터를 정의하는 문법과 비슷합니다. 참고로 여기에서 &는 반드시 붙여주어야 합니다.
3번째 줄은 employee 객체를 통해 methodPtr 포인터로 getSalary() 메소드를 호출합니다. 여기서 소괄호로 감싼 부분에 주목합니다. 메소드 이름 뒤에 나온 ()는 *보다 우선순위가 높기 때문에 employee.*methodPtr을 소괄호로 묶어야 이 부분을 먼저 처리합니다.
만약 객체에 대한 포인터가 있다면, '.*' 대신 '->*'를 사용할 수 있습니다.
int (Employee:: * methodPtr) () const { &Employee::getSalary };
Employee* employee{ new Employee { "John", "Doe" } };
std::cout << (employee->*methodPtr)() << std::endl;
그리고 타입 앨리어스를 사용하면 첫 번째 줄의 코드는 다음과 같이 읽기 쉽게 작성할 수 있습니다.
using PtrToGet = int (Employee::*) () const;
PtrToGet methodPtr{ &Employee::getSalary };
Employee employee{ "John", "Doe" };
std::cout << (employee.*methodPtr)() << std::endl;
마지막으로 auto를 사용하면 더 간단하게 작성할 수 있습니다.
auto methodPtr{ &Employee::getSalary };
Employee employee{ "John", "Doe" };
std::cout << (employee.*methodPtr)() << std::endl;
std::mem_fn()을 사용하면 .* 이나 ->* 문법을 사용할 필요가 없습니다. std::mem_fn()은 다음 포스팅 function object에서에서 알아보도록 하겠습니다.
프로그램을 작성할 때 메소드나 데이터 멤버에 대한 포인터를 사용할 일이 많지는 않지만, non-static 메소드나 데이터 멤버에 대한 포인터는 객체를 거치지 않고서는 역참조할 수 없다는 사실을 알고 있으면 좋습니다. 프로그래밍을 하다 보면 qsort()와 같은 함수 포인터를 받는 함수에 non-static 메소드의 포인터를 전달하는 실수를 저지르기 쉬운데, 이렇게 작성하면 작동하지 않습니다.
2.1 std::function
<functional>에 정의된 std::function 템플릿을 이용하면 함수를 가리키는 타입, 함수 객체, 람다 표현식을 비롯하여 호출 가능한(callable) 모든 대상을 가리키는 타입을 생성할 수 있습니다. std::function을 다형성 함수 래퍼(polymorphic function wrapper)라고도 부르며, 함수 포인터로 사용할 수도 있고, 콜백을 구현하는 함수를 나타내는 매개변수로 사용할 수도 있습니다. std::function의 템플릿 매개변수는 다른 템플릿 매개변수와 조금 다른데, 문법은 다음과 같습니다.
std::function<R(ArgTypes...)>
여기서 R은 리턴 타입이고, ArgTypes는 각각을 콤마로 구분한 매개변수의 타입 목록입니다.
std::function으로 함수 포인터를 구현하는 방법은 다음과 같습니다. 여기서는 func()을 가리키는 f1이란 함수 포인터를 생성합니다. f1을 정의한 뒤에는 func나 f1이란 이름만으로 func()을 호출할 수 있습니다.
void func(int num, std::string_view str)
{
std::cout << "func(" << num << ", " << str << ")\n";
}
int main()
{
std::function<void(int, std::string_view)> f1{ func };
f1(1, "test");
}
클래스 템플릿 인수 추론(CTAD) 덕분에, 템플릿 인수를 생략하여 다음과 같이 f1을 생성할 수도 욌습니다.
std::function f1{ func };
물론, 이 예제에서 auto 키워드를 사용하면 f1 앞에 타입을 구체적으로 지정하지 않아도 됩니다.
즉, 다음과 같이 정의하면 앞의 코드들과 같은 기능을 훨씬 간결하게 표현할 수 있습니다. 하지만 이렇게 하면 컴파일러는 f1의 타입이 std::function이 아니라 함수 포인터라고 판단합니다. 그래서 void (*f1) (int, const string&)으로 변환합니다.
auto f1{ func };
std::function 타입은 함수 포인터처럼 동작하기 때문에 표준 라이브러리 알고리즘에 인수로 전달할 수 있습니다. 위에서 정의한 findMatches 함수를 std::function을 사용하도록 다시 작성할 수 있습니다. 단지 타입 앨리어스만 변경해주면 됩니다.
using MatchFunction = std::function<bool(int, int)>;
void findMatches(int values1[], int values2[], size_t numValues,
MatchFunction matcher)
{
for (size_t i = 0; i < numValues; i++) {
if (matcher(values1[i], values2[i])) {
std::cout << "Match found at position " << i <<
" (" << values1[i] << ", " << values2[i] << ")" << std::endl;
}
}
}
이런 종류의 콜백은 많은 표준 라이브러리 알고리즘에서 사용합니다.
그러나 기술적으로 findMatches() 함수가 콜백 파라미터를 받는데 std::function 파라미터를 사용하지 않아도 됩니다. 대신 findMatches()를 함수 템플릿으로 변환할 수 있습니다. 단시 MatchFunction 타입 앨리어스를 제거하고 이를 함수 템플릿으로 만들어주면 됩니다.
template<typename MatchFunction>
void findMatches(int values1[], int values2[], size_t numValues,
MatchFunction matcher)
{
for (size_t i = 0; i < numValues; i++) {
if (matcher(values1[i], values2[i])) {
std::cout << "Match found at position " << i <<
" (" << values1[i] << ", " << values2[i] << ")" << std::endl;
}
}
}
이렇게 구현한 findMatches는 템플릿 타입 파라미터를 받습니다. 또한 함수 템플릿 인수 추론(function template argument deduction), 이 함수를 호출하는 방법은 이전과 동일합니다.
만약 C++20의 abbreviated function template 문법을 사용하면 findMatches() 함수 템플릿은 다음과 같이 작성할 수 있습니다.
void findMatches(int values1[], int values2[], size_t numValues,
auto matcher)
{
/* ... */
}
이처럼 findMatches() 함수 템플릿이나 abbreviated function template은 실제로 권장되는 방법입니다. 위의 예시들에서 std::function이 그다지 유용하지 않은 것처럼 보이지만 std::function 함수는 콜백을 클래스의 데이터 멤버로 저장해야할 때 매우 유용합니다.
이번 포스팅에서 함수 포인터에 대해 알아봤습니다. 다음 포스팅에서는 이와 비슷한 함수 객체(Function Object)에 대해서 알아보도록 하겠습니다 !
'프로그래밍 > C & C++' 카테고리의 다른 글
[C++] Lambda Expression (람다 표현식) (0) | 2022.02.23 |
---|---|
[C++] Function Object (함수 객체) (0) | 2022.02.22 |
[C++] Iterator (이터레이터, 반복자) (0) | 2022.02.21 |
[C++] 연산자 오버로딩 (2) (0) | 2022.02.20 |
[C++] 연산자 오버로딩 (1) (0) | 2022.02.20 |
댓글