
void pointer
void 포인터는 generic pointer (일반 포인터)라고도 불리고 어떤 데이터 타입의 객체라도 가리킬 수 있는 특수한 포인터이다
일반 포인터와 같은 방식으로 선언되고 타입으로 void 키워드가 사용된다
void* ptr{};
void 포인터는 어떤 데이터 타입의 객체든 가리킬 수 있는 포인터이기 때문에 다음과 같이 사용이 가능하다
struct FooStruct
{
int a{};
};
int main()
{
int iVal{};
float fVal{};
FooStruct sVal{};
void* vPtr{};
vPtr = &iVal;
vPtr = &fVal;
vPtr = &sVal;
return 0;
}
대상의 구체적인 타입은 신경쓰지 않고 주소 값 자체만을 저장하기 때문이다
void 포인터는 자신이 가리키고 있는 대상의 타입을 알 수 없기 때문에 바로 역참조를 할 수 없다, 다른 포인터 타입으로 캐스팅하고 역참조를 해야한다
int main()
{
int iVal{ 10 };
void* vPtr{ &iVal };
*vPtr; //error
int* iPtr{ static_cast<int*>(vPtr) }; //casting
*iPtr; //ok
return 0;
}
결국 void 포인터를 어떤 타입으로 캐스팅 하느냐?는 프로그래머의 책임이다, 이것이 바로 void 포인터 사용의 주된 어려움과 위험성이다
컴파일러는 해당 void 포인터가 어떤 타입의 값을 가리키는지 알 수 없다, 따라서 프로그래머가 직접 void 포인터를 알맞는 타입에 캐스팅해서 사용해야 한다, 이때 다른 타입으로 캐스팅하고 역참조를 한다면 의도치 않은 동작을 발생시킬 수 있다
void 포인터는 다음과 같은 예시 코드로 사용이 가능하다
enum class Type
{
intType,
floatType,
CStringType
};
void printValue(void* ptr, Type t)
{
switch (t)
{
case Type::intType:
std::cout << *static_cast<int*>(ptr) << '\n';
break;
case Type::floatType:
std::cout << *static_cast<float*>(ptr) << '\n';
break;
case Type::CStringType:
// char 포인터로 캐스팅 (역참조 없음)
// std::cout은 char*를 C-Style 문자열로 취급
std::cout << static_cast<char*>(ptr) << '\n';
break;
default:
break;
}
}
int main()
{
int val1{ 10 };
float val2{ 10.5 };
char val3[]{ "Kelvin" };
printValue(&val1, Type::intType);
return 0;
}
이렇게 하나의 함수로 다양한 데이터 타입을 처리할 수 있게 된다 (프로그래머의 관리가 필요함, 사실 함수 오버로딩을 사용하는게 더 적합하다)
void 포인터도 다른 포인터와 마찬가지로 nullptr을 가질 수 있다
void* ptr{ nullptr };
void 포인터는 자신이 가리키는 객체의 타입을 알지 못하기 때문에 delete로 동적 메모리 해제가 불가능하다 (정의되지 않은 동작 발생 가능)
만약 void 포인터가 가리키는 동적 할당된 메모리를 해제해야 한다면 다른 타입으로 캐스팅 후 delete 해야 한다
(delete 연산자는 메모리 해제전에 객체의 소멸자를 호출하려고 시도하지만 타입을 모르기 때문에 어떤 소멸자를 호출하고 얼마나 많은 메모리를 해제해야 하는지 알 수 없다, 메모리 누수와 잘못된 소멸자 호출 등 문제가 발생할 수 있다)
int* ptrInt{ new int{} };
void* vPtr{ ptrInt };
delete vPtr; //undefined behavior!
int* ptrInt1{ new int{} };
void* vPtr1{ ptrInt1 };
delete static_cast<int*>(vPtr1); //good
또한 void 포인터는 산술 연산이 불가능하다, 포인터가 가리키는 객체의 타입을 알아야 산술 연산으로 해당 타입의 크기만큼 증가, 감소가 가능한데 void포인터는 타입을 모르기 때문이다
(일부 컴파일러에서는 void* 산술 연산 시 1byte 이동 처리를 하기도 함, 표준은 아님)
void는 참조가 존재하지 않는다 (void& 이런건 없음), 어떤 타입의 값을 참조하는지 알 수 없기 때문이다
참조는 항상 특정 타입의 유효한 객체를 가리켜야 하지만 void는 그럴 수 없기 때문이다
void 포인터는 어떤 타입이든 전부 들어갈 수 있어서 타입 검사를 피할 수 있게 만들기 때문에 꼭 필요한 경우가 아니라면 사용을 지양하는게 좋다 (타입 안전성을 해칠 수 있다)
여러 데이터 처리가 필요하다면 타입 검사를 확실하게 할 수 있는 template을 사용하는걸 권장한다 (템플릿 특수화 활용)
그럼에도 불구하고 void 포인터가 사용되는곳은 어디일까?
C library & interface
C의 다양한 library 함수에서 데이터 처리를 위해 void* 매개변수나 반환형을 사용한다 ex) memcpy, qsort, malloc의 return 타입
저수준 메모리 조작
하드웨어나 메모리를 직접 제어해야 할 경우 타입 없이 순수한 메모리 주소로 작업을 할 때 사용한다
void 포인터 대신에 사용할 대표적인 방식 2개를 정리해보자
#include <variant>
int main()
{
std::variant<int, double, std::string> var1;
var1 = 10;
std::get<int>(var1);
return 0;
}
std::variant는 지정된 타입에 해당하는 값만 할당이 가능하다 (단 하나의 값만 저장 가능)
(위에서는 int, double, std::string 이외의 타입을 할당하면 컴파일 에러 발생, 두개 이상을 저장하면 예외가 발생한다)
std::get< T > 로 variant에 T타입의 값이 저장되어 있으면 해당 값을 return한다, 만약 비어있는 상태라면
std::bad_variant_access 예외를 발생시키고 2개 이상의 값이 들어가 있어도 예외가 발생한다
std::cout << std::get< index >(var1); 으로도 값을 가져올 수 있다, 여기서 index는 타입의 순서이다
std::get_if< T >(&variant) 혹은 std::get_if< index >(&variant)로 해당 타입 혹은 인덱스의 값이 존재한다면 해당 값에 대한 포인터를 return하고 없다면 nullptr을 반환한다 (예외 발생X)
std::variant<int, double, std::string> var1;
var1 = 10;
int* ptr{ std::get_if<int>(&var1) };
//만약 double로 처리했다면 nullptr이 나왔을것
index()로 variant에 저장된 타입 인덱스를 얻을 수 있다
var1.index(); //int = 0, double = 1, std::string = 2
valueless_by_exception()으로 variant가 유효하지 않은 값을 가지고 있지 않은 상태(exception을 날리는 객체와 같은)인지를 bool로 확인한다
std::variant는 저장될 수 있는 명확한 타입이 지정되어 코드의 의도를 명확히하고 관리에 좋다
std::any는 임의의 단일 값을 안전한 타입으로 저장할 수 있는 클래스이다, std::variant와 다르게 저장할 수 있는 타입에 대한 제약이 없다
#include <any>
std::any testany;
testany = 10;
std::any_cast< T >(any);로 T타입 값을 return할 수 있다, 만약 타입이 일치하지 않거나 비어있으면 std::bad_any_cast 예외를 발생시킨다
std::any_cast<int>(testany);
std::any_cast< T >(&any);로 T타입의 값이 저장되어 있으면 해당 값의 포인터를 반환하고 그렇지 않다면 nullptr을 반환한다, 예외를 발생시키지 않는다
has_value()로 std::any 객체가 값을 가지고 있는지 확인할 수 있다
testany.has_value();
type()으로 저장된 값의 std::type_info 객체를 return한다, 실제 타입을 확인할 수 있다
testany.type().name();
reset()으로 저장된 값을 제거하여 std::any 객체를 비워준다
testany.reset();
std::any는 std:variant보다 런타임 오버헤드가 발생할 수 있다 (런타임 타입 체크가 발생하기 때문)
또한 타입 제약이 전혀 없기 때문에 예외 발생으로 조심해야 한다
함수 포인터
포인터는 다른 변수의 주소를 담는 변수로서 특정 객체를 가리킨다
함수 포인터도 비슷하다 함수 포인터는 변수 대신 함수를 가리킨다
함수는 자신만의 함수 타입을 가진다
int foo()
{
return 10;
}
foo()는 int 반환형에 매개변수가 없는 함수 타입이다, 변수처럼 함수도 메모리 내 할당된 주소에 존재한다 (따라서 lavlue표현식임)
함수가 operator()로 호출될 때 호출되는 함수의 주소로 점프한다
예를들어 foo()의 주소가 0x001928f0이라면 해당 주소로 점프하는것이다
다음과 같은 코드를 실행하면 어떻게 될까?
std::cout << foo << std::endl;
함수는 operator()없이 이름으로 참조되면 함수 포인터로 변환한다, 하지만 operator <<는 함수 포인터를 처리할 수 없기 때문에 bool로 변환되게 된다 (nullptr이 아니기 때문에 true가 나옴)
하지만 visual studio는 함수 주소를 출력하는 컴파일러 확장이 있기 때문에 함수의 주소가 나오게 된다
만약 vs가 아니고 함수 주소를 출력하고 싶다면 void*로 캐스팅해서 사용하면 된다
reinterpret_cast<void*>(foo)
non-const 함수 포인터
non-const 함수 포인터는 다음과 같이 선언할 수 있다
int (*funcPtr)();
이 funcPtr은 int 반환형을 가지고 매개변수가 없는 함수를 가리키는 포인터이다, 타입이 일치하는 어떤 함수든 가리킬 수 있다
이때 ()를 사용하지 않는다면 int* funcPtr()로 전방선언으로 해석되기 때문에 ()를 사용해줘야 한다
const 함수 포인터
const 함수 포인터는 위 구문에 const를 붙이면 된다
int (*const funcPtr)();
이제 funcPtr은 상수 함수 포인터로서 한번 특정 함수를 가리키도록 초기화 된 후 다른 함수를 가리키도록 변경이 불가능해진다
이때 const int (*funcPtr)();은 const int 타입을 반환하는 매개변수 없는 함수를 가리키는 포인터가 된다, 한번 초기화 된 후 다른 함수를 가리키도록 변경이 가능하다
const int (*const funcPtr)()로 const int타입을 반환하는 매개변수 없는 함수를 가리키는 상수 포인터를 만들 수 있다 (둘 다 변경 불가능)
함수 포인터에 함수 할당
함수 포인터는 함수로 초기화 될 수 있으며 &를 통해 함수에 대한 함수 주소를 얻을 수 있다
int foo()
{
return 5;
}
int goo()
{
return 10;
}
int main()
{
int (*fooPtr)() { &foo }; //함수의 주소로 함수 포인터 초기화, &는 암시적 변환이 되기 때문에 굳이 안 써도 된다
fooPtr = &goo; //fooPtr은 foo함수를 가리키다가 goo 함수를 가리키도록 변경됨
return 0;
}
주의할 점은 함수이름만 사용해야 한다는 점이다, ()를 사용하게 되면 반환값을 넣게 되기 때문에 사용하면 안된다
C++은 함수를 함수 포인터로 암시적 변환하기 때문에 &를 반드시 사용할 필요는 없다
int (*fooPtr)() { foo }; //이렇게도 가능
함수 포인터의 타입(매개변수와 반환형)은 반드시 함수의 타입과 일치해야 한다
double foo();
int (*funcPtr)() { foo }; //error
함수 포인터도 일반 포인터와 마찬가지로 nullptr 값으로 초기화되거나 할당이 가능하다
int (*funcPtr)() { nullptr };
함수 포인터를 사용하여 함수 호출
함수 포인터를 이용하여 실제 함수를 호출해보자
역참조를 하고 매개변수를 넘겨주면 함수 포인터를 이용하여 실제 함수를 호출할 수 있다
int foo(int a)
{
return a;
}
int main()
{
int (*fooPtr)(int) { &foo };
std::cout << (*fooPtr)(100);
return 0;
}
이때 명시적인 역참조가 아닌 암시적 역참조를 이용하여 조금 더 편하게 함수 호출이 가능하다
fooPtr(100); //일반 함수 호출과 형태가 같지만 함수 포인터를 이용한 함수 호출임 (암시적 역참조)
어차피 일반 함수 이름도 함수를 가리키는 포인터이기 때문에 위 같은 암시적 역참조를 이용한 함수 호출되 가능한건 자연스럽다
함수 포인터는 nullptr이 가능하기 때문에 nullptr 체크를 하고 사용하는걸 강력히 권장한다
if (!fooPtr)
{
return;
}
fooPtr(100);
함수 포인터를 이용하여 기본 인자가 있는 함수와 그렇지 않은 함수 오버로딩에서의 모호함을 해소할 수 있다
함수의 기본인자는 컴파일 타임에 처리된다, 컴파일러는 해당 함수 호출 시 생략된 인자에 기본값을 채워넣어 준다
하지만 함수 포인터를 통해 함수를 호출하게 되면 런타임에 어떤 함수가 호출될지 결정되기 때문에 기본 인자를 적용할 수 없다
void foo(int a)
{
std::cout << a << std::endl;
}
void foo(int a, int b = 100)
{
std::cout << a << b << std::endl;
}
int main()
{
foo(100); //foo(int)인지 foo(int, int)인지 모호하기 때문에 컴파일 에러 발생
return 0;
}
void foo(int a)
{
std::cout << a << std::endl;
}
void foo(int a, int b = 100)
{
std::cout << a << b << std::endl;
}
int main()
{
using testPtr = void(*)(int);
testPtr tPtr{ foo };
tPtr(100); //함수 포인터를 이용하여 함수 호출에 대한 모호함이 사라짐
return 0;
}
//or
int main()
{
static_cast<void(*)(int)>(foo)(1); //이런식으로 바로 함수 포인터로 캐스팅해서 사용도 가능
}
다른 함수에 함수를 인자로 전달
사실 함수 포인터를 사용하는 가장 큰 이유는 다른 함수에 함수를 인자로 전달 할 수 있기 때문이다
이러한 인자로 전달되는 함수를 call back function이라고 한다
정렬에서 정렬 기준을 함수 포인터로 넘기는 예시로 정리해보자
void SelectionSort(int* array, int size)
{
if (!array)
{
return;
}
for (int startIndex{ 0 }; startIndex < (size - 1); ++startIndex)
{
int smallestIndex{ startIndex };
for (int currentIndex{ startIndex + 1 }; currentIndex < size; ++currentIndex)
{
if (array[smallestIndex] > array[currentIndex])
{
smallestIndex = currentIndex;
}
}
std::swap(array[startIndex], array[smallestIndex]);
}
}
위와 같은 선택 정렬에서 조건은 결국 array[] > array[]가 되고 그 결과 오름차순 선택 정렬이 된다
이러한 조건을 함수 포인터를 사용하여 인자로 넘겨 받아 사용할 수 있다
bool ascending(int a, int b) //오름차순 조건 함수
{
return a > b;
}
bool descending(int a, int b) //내림차순 조건 함수
{
return a < b;
}
//조건을 넘길 함수 포인터를 인자로 지정
void SelectionSort(int* array, int size, bool (*compareFunc)(int, int))
{
if (!array)
{
return;
}
for (int startIndex{ 0 }; startIndex < (size - 1); ++startIndex)
{
int bestIndex{ startIndex };
for (int currentIndex{ startIndex + 1 }; currentIndex < size; ++currentIndex) {
//함수 포인터를 이용하여 조건 사용
if (compareFunc(array[bestIndex], array[currentIndex]))
{
bestIndex = currentIndex;
}
}
std::swap(array[startIndex], array[bestIndex]);
}
}
int main()
{
int array[6]{ 1, 5, 2, 7, 9, 3 };
SelectionSort(array, 6, descending);
SelectionSort(array, 6, ascending);
return 0;
}
위 코드로 호출자에게 선택 정렬이 어떤 조건을 통해 수행할 지 제어할 수 있는 능력이 부여되었다
이렇게 함수 포인터를 이용하여 원하는 함수를 전달하고 전달받은 함수를 함수 내부에서 사용할 수 있다 (위 코드에서 descending(), ascending()이 콜백 함수가 되었다)
함수 포인터를 사용하면 코드 유연성과 재사용성이 크게 향상된다
물론 이렇게 오름차순, 내림차순 뿐 아니라 프로그래머가 원하는 어떠한 기능도 콜백함수로 넘겨서 사용이 가능하다 ex) 짝수면 먼저, 홀수면 나중에 등등...
함수의 매개변수가 함수 타입이라면 해당 함수 타입에 대한 포인터로 변환된다
void SelectionSort(int* array, int size, bool (*compareFunc)(int, int))
//이 line은 다음과 같이 사용해도 동일하다
void SelectionSort(int* array, int size, bool compareFunc(int, int))
bool compareFunc(int, int)로 함수 타입을 넘겨도 결국 bool (*compareFunc)(int, int)와 같은 함수 타입에 대한 포인터로 변환되기 때문이다
단 중요한 점은 매개변수에서만 이렇게 사용이 가능하다는 점이다, 다른곳에서 사용할 시 함수의 전방선언으로 처리된다
매개변수의 default function
매개변수의 default value를 지정하는것 처럼 함수 매개변수로 사용할때도 default function을 지정할 수 있다
bool ascending(int a, int b)
{
return a > b;
}
bool descending(int a, int b)
{
return a < b;
}
//default function을 ascending함수로 지정
void SelectionSort(int* array, int size, bool (*compareFunc)(int, int) = ascending)
{
if (!array)
{
return;
}
for (int startIndex{ 0 }; startIndex < (size - 1); ++startIndex)
{
int bestIndex{ startIndex };
for (int currentIndex{ startIndex + 1 }; currentIndex < size; ++currentIndex) {
if (compareFunc(array[bestIndex], array[currentIndex]))
{
bestIndex = currentIndex;
}
}
std::swap(array[startIndex], array[bestIndex]);
}
}
int main()
{
int array[6]{ 1, 5, 2, 7, 9, 3 };
SelectionSort(array, 6); //함수를 넘기지 않아도 default function이 들어감
return 0;
}
함수 포인터 구문은 type aliases를 사용하여 가독성을 높이는 방식을 자주 사용한다
bool ascending(int a, int b)
{
return a > b;
}
bool descending(int a, int b)
{
return a < b;
}
using ptrFucnAliases = bool(*)(int, int); //함수 포인터 type 별칭
void SelectionSort(int* array, int size, ptrFucnAliases funcPtr) //type별칭으로 매개변수 사용
{
if (!array)
{
return;
}
for (int startIndex{ 0 }; startIndex < (size - 1); ++startIndex)
{
int bestIndex{ startIndex };
for (int currentIndex{ startIndex + 1 }; currentIndex < size; ++currentIndex) {
if (funcPtr(array[bestIndex], array[currentIndex]))
{
bestIndex = currentIndex;
}
}
std::swap(array[startIndex], array[bestIndex]);
}
}
int main()
{
int array[6]{ 1, 5, 2, 7, 9, 3 };
SelectionSort(array, 6, ascending);
return 0;
}
std::function (C++11)
STL의 < functional > 헤더의 일부인 std::function으로 함수 포인터를 사용할 수 있다
std::function은 함수 포인터, 함수 객체, 람다 표현식등 호출 가능한 모든것을 래핑할 수 있는 함수 래퍼이다
#include <functional>
void SelectionSort(int* array, int size, std::function<bool(int, int)> funcPtr)
std::function<반환형(매개변수 타입들)> 을 타입으로 사용하면 된다, 매개변수가 없다면 ()를 비워주면 된다
std::function<bool(int, int)> funcPtr{ ascending };
std::function은 명시적 역참조가 아닌 암시적 역참조로만 함수를 호출할 수 있다
std::function<bool(int, int)> funcPtr{ ascending };
funcPtr(10,20); //ok
(*funcPtr)(10, 20); //error
이러한 std::function도 type aliases를 사용하여 가독성을 높여 사용하는것도 좋다
using test = std::function<bool(int, int)>;
타입 별칭을 사용할때는 템플릿 인수를 초기화 할 초기화자가 없기 때문에 CTAD를 사용할 수 없다, 따라서 템플릿 인수를 명시적으로 지정해야 한다
일반 함수 포인터보다 타입 안전성이 높고 사용하기 쉽다, 함수 포인터 뿐만 아니라 다른 호출 가능한 것들을 동일한 방식으로 다룰 수 있다
C++17부터는 CTAD를 이용하여 std::function의 템플릿 인수를 자동 추론할 수 있다
std::function funcPtr{ ascending }; //CTAD로 초기화자를 통해 템플릿 인수 자동 추론
funcPtr(10, 20);
함수 포인터 with auto
auto 키워드를 통해 변수의 타입을 추론하듯 auto를 사용하여 함수 포인터의 타입도 추론이 가능하다
auto funcPtr{ ascending };
funcPtr(10, 20);

auto를 사용하게 되면 명확한 타입이 노출되지 않기 때문에 휴먼 에러가 발생할 확률이 높아진다