
가변 인자 리스트
이제까지 함수는 전달받을 매개변수의 수가 미리 알려져야 했다, 하지만 C++의 가변 인자 리스트(Ellipsis)지정자를 사용하면 가변적인 수의 매개변수를 함수로 전달할 수 있다
이러한 가변 인자 리스트는 잠재적으로 위험하기 때문에 권장하지 않는다
기본적인 사용법은 다음과 같다
반환형 함수이름(매개변수, ...);
가변 인자 리스트를 사용하는 함수는 최소 1개의 고정 매개변수는 가지고 있어야 한다, 또한 가변 인자 리스트인 ...은 항상 함수 매개변수 목록의 마지막에 위치해야 한다
#include <cstdarg> //...을 사용하기 위한 헤더
//가변 인자 리스트를 매개변수로 가지는 평균을 구하는 함수
double getAverage(int count, ...)
{
int sum{ 0 };
std::va_list list;
va_start(list, count);
for (int arg{ 0 }; arg < count; ++arg)
{
// 첫 번째 인자는 사용 중인 va_list
// 두 번째 인자는 값의 타입
sum += va_arg(list, int);
}
va_end(list);
return static_cast<double>(sum) / count;
}
int main()
{
std::cout << getAverage(5, 1, 2, 3, 4, 5);
return 0;
}
가변 인자 리스트는 이름이 존재하지 않는다, std::va_list라는 타입을 통해 가변 인자 리스트 값에 접근한다
, 이는 가변 인자 배열을 가리키는 포인터라고 생각할 수 있다
va_start(va_list, 마지막 고정 매개변수 이름);으로 va_list는 가변 인자 리스트의 첫 번째 매개변수를 가리키게 된다
va_list가 현재 가리키고 있는 값을 얻으려면 va_arg(va_list, 매개변수 타입);으로 얻어와야 한다, 이때 va_arg()는 va_list가 다음 매개변수를 가리키도록 변경한다
va_end(va_list)로 va_list가 마지막 매개변수를 가리키도록 한다
va_list를 가변 인자 리스트의 첫번재 매개변수로 가리키고 싶을때는 언제든 va_start()를 호출하면 된다
그렇다면 왜 가변 인자 리스트는 위험할까?
함수 호출 시 가변적인 갯수의 매개변수를 넘길 수 있도록 구현할 수 있기 때문에 굉장히 유연한 구문인데 왜 위험할까?
일반적인 함수를 사용할때는 컴파일러가 인자로 넘긴 값과 함수의 매개변수 타입이 일치하는지 혹은 암시적으로 변환될 수 있는지를 체크한다
그러나 가변 인자 리스트에는 타입 선언이 없기 때문에 컴파일러는 타입 검사를 중단한다, 따라서 어떤 타입의 인자라도 가변 인자 리스트로 넘길 수 있다는 것이다 (함수에서 처리할 수 없는 타입의 인자도 넘어감)
이러한 타입 검사 중단으로 인한 위험성이 높아진다
std::cout << findAverage(6, 1.0, 2, "Kelvin", 'A', &value, &findAverage) << '\n';
이런 말도안되는 함수 호출이 가능해지며 전혀 의도하지 않은 값이 출력되게 될 것이다
또한 가변 인자 리스트는 전달된 매개변수의 개수를 알지 못하기 때문에 위험하다
(직접 가변 인자 리스트로 전달된 매개변수 개수를 얻기 위한 솔루션을 개발해야 한다)
간단한 방법들이 존재한다
그 중 하나는 매개변수 길이를 전달하는 것이다 (위의 평균 구하는 함수처럼)
하지만 이런 방법은 굉장히 위험하다, 휴먼 에러로 틀린 개수를 넘기게 되면 의도치 않은 값이 나오게 된다
std::cout << findAverage(6, 1, 2, 3, 4, 5, 6, 7) << '\n';
//3.5를 예상하지만 6이라는 틀린 개수를 넘겼기 때문에 값이 달라짐
두번째는 감시값(Sentinel Value)를 사용하는 것이다
감시값이란 loop가 해당 값을 만나면 종료하는데 사용되는 특별한 값을 의미한다
ex) 문자열에서 \0(null)
일반적으로 가변 인자 리스트의 마지막 매개변수로 감시값을 전달하여 해당 감시값이 나오게 되면 loop를 멈추는 등과 같은 방법을 사용한다
#include <cstdarg>
double getAverage(int first, ...)
{
int sum{ first }; //1번째 값
std::va_list list;
va_start(list, first);
int count{ 1 }; //1번째 값은 이미 들어가 있기 때문에
while (true)
{
int arg{ va_arg(list, int) };
if (arg == -1) //Sentinel value
{
break;
}
sum += arg;
++count;
}
va_end(list);
return static_cast<double>(sum) / count;
}
int main()
{
std::cout << getAverage(1, 2, 3, 4, 5, -1); //Sentinel value -1
return 0;
}
물론 이러한 방법도 휴먼 에러가 발생하기 굉장히 쉽다, 일단 명시적으로 Sentinel value를 넣어줘야 하고 고정 매개변수에 연산에 들어갈 1번째 값이 들어가야 한다는 점 등 위험성이 존재한다
가능한 가변 인자 리스트는 사용하지 않는걸 강력히 권장한다, 만약 꼭 사용해야 한다면 가변 인자 매개변수의 타입은 모두 동일 타입으로 사용하는게 좋다, 또한 Sentienl value보다 매개변수의 개수를 넘기는것이 더욱 안전하다
C++17은 C++11에서의 가변 인자 리스트와 유사한 기능을 제공하지만 강력한 타입 검사를 지원하는 parameter packs의 사용성을 향상시키는 폴드 표현식(fold expression)이 추가되었다
매개변수 팩 (C++11)과 폴드 표현식 (C++17)
매개변수 팩을 사용하여 함수가 임의의 개수의 인자를 받을 수 있게 해준다
폴드 표현식은 이러한 매개변수 팩의 모든 element에 대한 특정 이항 연산자를 반복적으로 적용하여 하나의 결과로 만들어주는 표현식이다
template<typename... Args> //Args는 템플릿 매개변수 팩
double getAverage(Args... args) //args는 함수 매개변수 팩
{
int sum{ (args + ...) }; //폴드 표현식으로 전부 더하기
size_t count{ sizeof...(args) }; //개수 구하기
return sum / count;
}
int main()
{
std::cout << getAverage(1, 2, 3, 4, 5);
return 0;
}
폴드 표현식의 4가지 형태를 정리해보자
(... + args)
pack의 element들에 대해 오른쪽에서 왼쪽으로 op를 적용한다
ex) 5 + 4 + 3 + 2 + 1
(args + ...)
pack의 element들에 대해 왼쪽에서 오른쪽으로 op를 적용한다
ex) 1 + 2 + 3 + 4 + 5
//int init{6};
(args + ... + init)
init값으로 시작해서 pack의 element들에 대해 오른쪽에서 왼쪽으로 op를 적용한다
ex) 6 + 5 + 4 + 3 + 2 + 1
//int init{6};
(init + ... + args)
pack의 element들에 대해 오른쪽에서 왼쪽으로 op를 적용하고 init값에 op를 적용한다
ex) 1 + 2 + 3 + 4 + 5 + 6
위 방식들을 사용할 때 pack이 비어있으면 init이 있는 3번과 4번은 결과값이 init이 되고 init이 없다면 특정한 op외에는 사용할 수 없다 (컴파일 에러)
여기서 특정한 op인&&,||, ,은 &&(true), ||(false), ,(void())를 return한다
lambda (익명 함수)
이전에 정리했던 특정 문자열이 있는지 체크하고 있으면 해당 문자열을 반환하는 기능의 코드를 다시 정리해보자
bool containsGreen(std::string_view str)
{
return str.find("Green") != std::string_view::npos;
}
int main()
{
constexpr std::array<std::string_view, 3> arr{ "Red", "Green", "Blue"};
auto found{ std::find_if(arr.begin(), arr.end(), containsGreen) };
if (found == arr.end())
{
std::cout << "No Green\n";
}
else
{
std::cout << "Found " << *found << '\n';
}
return 0;
}
이 코드에는 작은 문제점이 있는데, 일단 std::find_if()는 마지막 인자로 함수 포인터를 전달해야 한다, 이때 만약 constainsGreen()을 한번만 사용하고 버릴 함수라면 프로그래머는 단 한번만 사용할 함수를 정의해야 한다는 점이다
lambda는 익명 함수이다(함수 이름을 지을 필요가 없다), lambda 표현식을 사용하여 함수 내부에서 익명 함수를 정의할 수 있다
이는 전역에 선언되지 않아 namespace를 깔끔하게 유지할 수 있고 함수가 사용되는 사용처와 가깝게 정의할 수 있게 해준다
기본적인 lambda표현식은 다음과 같다
[캡처](매개변수) -> 반환형
{
//code
}
lambda에서 캡처와 매개변수가 필요하지 않다면 비워둘 수 있다, 또한 반환형을 생략하여 명시적으로 지정하지 않아도 된다 (반환형을 생략할 시 auto로되어 반환형이 결정된다, 함수 반환형에 대한 타입 추론은 지양하는게 좋다고 했지만 lambda에서는 예외임(굉장히 간단한 함수이기 때문))
위의 문자열 검색 로직을 lambda를 사용하여 다시 구현해보자
auto found{ std::find_if(arr.begin(), arr.end(),
[](std::string_view str) //lambda
{
return str.find("Green") != std::string_view::npos;
}) };
단 이렇게 코드에 직접적으로 lambda를 넣게 되면 가독성이 굉장히 저하될 수 있다, 일반 변수처럼 변수를 lambda로 초기화 후 해당 변수를 나중에 사용할 수 있다
auto FindGreen
{
[](std::string_view str)
{
return str.find("Green") != std::string_view::npos;
}
};
auto found{ std::find_if(arr.begin(), arr.end(), FindGreen) };
이렇게 변수에 lambda를 저장하면 재활용도 가능하다
이러한 lambda는 프로그래머가 명시적으로 사용할 수 있는 타입을 가지고 있지 않는다, 컴파일러가 프로그래머에게 노출되지 않는 lambda만의 고유한 타입을 생성한다
변수에 lambda를 넣을때 실제 타입을 사용하는 유일한 방법은 au to이다
lambda는 익명 함수라고 하지만 실제로는 함수가 아니고 functor이다, functor는 함수처럼 호출할 수 있도록 operator()가 오버로딩 된 객체이다
lambda의 캡처가 비어있다면 함수 포인터에 사용이 가능하다
int (*addNum)(int, int) //함수 포인터에 사용 가능
{
[](int a, int b)
{
return a + b;
}
};
int temp{ addNum(10, 20) };
마찬가지로 std::function에도 사용이 가능하다
std::function addNum
{
[](int a, int b)
{
return a + b;
}
};
int temp{ addNum(10, 20) };
그렇다면 이러한 lambda를 함수의 인자로 전달하려면 어떻게 할까?
int main()
{
auto addNum
{
[](int a, int b) -> int
{
return a + b;
}
};
return 0;
}
void Foo1(const std::function<int(int, int)>& fn)
{
std::cout << fn(10, 20);
}
lambda가 std::function으로 암시적 변환되어야 하기 때문에 아주 약간의 오버헤드가 발생할 수 있다
template <typename T>
void Foo1(const T& fn)
{
std::cout << fn(10, 20);
}
매개변수의 타입이 명확하지 않을 수 있다
void Foo1(const auto& fn)
{
std::cout << fn(10, 20);
}
사실상 2번과 동일한 함수 템플릿을 생성한다
void Foo1(int (*fn)(int, int))
{
std::cout << fn(10, 20);
}
lambda가 암시적 변환으로 함수 포인터로 변환된다
일반적으로 lambda를 변수에 저장할때는 auto를 사용하고 함수에 전달할때는 std::function을 사용하거나 C++20에서는 그냥 타입을 auto로 사용하는것도 권장하는 방법이다
Generic Lambda
C++14부터는 lambda의 매개변수에 auto를 사용할 수 있다 (C++20부터는 일반 함수도 매개변수에 auto를 사용할 수 있다)
하나 이상의 auto 매개변수를 가진 lambda는 다양한 타입과 함께 동작할 수 있기 때문에 Generic Lambda라고 부른다
다음 코드로 정리해보자
#include <algorithm> //std::adjacent_find
constexpr std::array months //CTAD로 타입 추론
{
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December"
};
const auto sameLetter{ std::adjacent_find(months.begin(), months.end(),
[](const auto& a, const auto& b) { // auto 매개변수
return a[0] == b[0];
}) };
if (sameLetter != months.end())
{
std::cout << *sameLetter << " and " << *std::next(sameLetter)
<< " start with the same letter\n";
}
위 코드는 첫 글자가 같은 연속적인 month를 출력하는 코드이다
std::adjacent_find()를 통해 months의 시작 ~ 끝을 순회하며 연속적인 element들을 조건식에 적용하여 true면 첫번째 값을 return한다
(June, July 연속적인 값에서 a[0], b[0]이 J로 같기 때문에 SameLetter에는 June이 들어간다)
std::next()로 June의 다음 element를 출력하여 June, July가 출력되게 된다
이때 lambda의 매개변수를 auto로 사용했기 때문에 해당 문자열이 C-style 문자열인지, std:string인지 신경 쓸 필요가 없어진다, 또한 모든 타입에 대해 적용이 가능하다는 장점이 있다
(위 예시에서는 const char*로 타입 추론됨)
하지만 auto를 사용하여 const char*로 자동 추론 시 std::string에서의 유용한 length()와 같은 멤버 함수를 사용하지 못한다는 단점이 있다
constexpr lambda
C++17부터 lambda에 캡처가 없거나 모든 캡처가 constexpr이고 lambda를 사용하는 함수가 constexpr이라면 해당 lambda는 암시적으로 constexpr이다
예를들면 C++17까지는 std::count_if가 consexpr이 아니어서 lambda가 constexpr이 될 수 없었지만 C++20부터는 std::count_if가 constexpr이 되어 조건에 사용되는 lambda가 constexpr이 될 수 있게되었다
lambda가 constexpr이 되면 컴파일 타임에 해당 lambda 함수의 계산을 끝낼 수 있다
constexpr auto fiveLetterMonths{ std::count_if(months.begin(), months.end(),
[](std::string_view str) {
return str.length() == 5;
}) };
Generic lambda & static variable
함수 템플릿 인스턴스화에서 함수 템플릿에 static 지역 변수가 있다면 해당 템플릿에서 인스턴스화 된 함수들은 각각 독립적인 static 지역 변수를 갖게된다
이는 Generic Lambda에서도 동일하다, auto가 추론되는 각각 타입에 따라 고유한 lambda가 인스턴스화 되고 타입에 따라 독립적인 static 지역 변수를 갖게 된다
auto print{
[](auto value) { // Generic lambda
static int callCount{ 0 }; // static local variable
std::cout << callCount++ << ": " << value << '\n';
}
};
print("hello"); // 0: hello (문자열 버전 lambda호출)
print("world"); // 1: world (문자열 버전 lambda호출)
print(1); // 0: 1 (정수 버전 lambda호출)
print(2); // 1: 2 (정수 버전 lambda호출)
각기 다른 타입의 lambda들에 있는 static local 변수들은 공유되지 않는다
각 타입의 lambda에 공유되는 변수를 가지려면 전역 변수나 lambda 외부에서 static local 변수를 정의해야 한다
return타입 추론 lambda
만약 lambda에서 return type을 명시적으로 지정하지 않는다면 해당 lambda에서 return하는 모든 값은 타입이 동일해야 한다 (만약 다르면 컴파일러가 어떤 타입을 선택해야할지 모름)
auto divide{ [](int a, int b, bool bIsIntDiv) {
if (bIsIntDiv)
{
return a / b;
}
else
{
return static_cast<double>(a) / b; //error
}
}};
이때 반환 타입을 명시적으로 지정하면 암시적 변환으로 컴파일이 된다
auto divide{ [](int a, int b, bool bIsIntDiv) -> double {
if (bIsIntDiv)
{
return a / b; //double로 암시적 캐스팅
}
else
{
return static_cast<double>(a) / b;
}
}};
STL 함수 객체
일반적인 덧셈, 비교 등과 같은 연산은 프로그래머가 자체적으로 lambda를 작성할 필요가 없이 STL에서 제공하는 함수 객체들을 사용하면 된다
#include <functional> //std::greater
std::array arr{ 13, 90, 99, 5, 40, 80 };
std::sort(arr.begin(), arr.end(), std::greater{}); // STL제공 함수 객체인 std::greater{}를 전달
for (int i : arr)
{
std::cout << i << ' '; //99, 90, 80...
}