C++ 기본 인자 규칙: 헤더에만 두는 이유와 가상 함수에서 생기는 함정
이 글의 핵심
기본 인자는 함수 하나로 여러 호출 형태를 지원할 수 있어 편리하지만, 기본값이 호출 지점에서 채워지기 때문에 가상 함수에서는 파생 클래스에 지정한 기본값이 무시될 수 있고, 포인터 기본값이나 오버로딩과 섞이면 호출이 모호해집니다. 오버로딩과 빌더 패턴 중 무엇이 나은지, 하위 호환성을 유지하며 매개변수를 추가하는 방법을 함께 다룹니다.
기본 인자란?
기본 인자(default arguments) 는 함수 매개변수에 기본값을 지정하여, 호출 시 인자를 생략할 수 있게 하는 기능입니다. 함수 오버로딩을 대체하여 코드를 간결하게 만들 수 있습니다.
void greet(const std::string& name = "Guest") {
std::cout << "Hello, " << name << "!" << std::endl;
}
int main() {
greet("Alice"); // Hello, Alice!
greet(); // Hello, Guest!
}
기본 인자를 이해하는 핵심은 기본값이 호출하는 쪽에서 채워진다는 점입니다. 컴파일러는 greet()를 보면 그 자리에서 greet("Guest")로 바꿔 컴파일하고, greet 함수 자체는 항상 인자 하나를 받는 평범한 함수로 남습니다. 이 사실에서 이 글의 거의 모든 규칙과 함정이 나옵니다. 호출하는 쪽이 기본값을 알아야 하므로 기본값은 호출자가 보는 선언(헤더)에 있어야 하고, 가상 함수에서도 호출 코드가 보는 정적 타입의 기본값이 쓰이며, 기본값을 바꾸면 그 헤더를 포함한 모든 파일을 다시 컴파일해야 바뀐 값이 반영됩니다.
왜 필요한가?:
- 간결성: 오버로딩 없이 선택적 매개변수 구현
- 하위 호환성: 기존 함수에 새 매개변수 추가 시 기존 코드 유지
- 가독성: 자주 사용하는 값을 기본값으로 설정
// ❌ 오버로딩: 코드 중복
void connect(const std::string& host) {
connect(host, 8080);
}
void connect(const std::string& host, int port) {
// 연결 로직
}
// ✅ 기본 인자: 간결
void connect(const std::string& host, int port = 8080) {
// 연결 로직
}
기본 사용법
// 단일 기본 인자
void func(int x = 10) {
std::cout << x << std::endl;
}
// 여러 기본 인자
void func(int x = 10, int y = 20, int z = 30) {
std::cout << x << ", " << y << ", " << z << std::endl;
}
int main() {
func(); // 10, 20, 30
func(1); // 1, 20, 30
func(1, 2); // 1, 2, 30
func(1, 2, 3); // 1, 2, 3
}
규칙
// ✅ 오른쪽부터 기본값
void func(int x, int y = 20, int z = 30) {
// OK
}
// ❌ 중간에 빈 자리
// void func(int x, int y = 20, int z) { // 에러
// // ...
// }
// ✅ 모두 기본값
void func(int x = 10, int y = 20, int z = 30) {
// OK
}
기본 인자 규칙 상세:
- 오른쪽부터 연속: 기본값이 있는 매개변수는 오른쪽부터 연속되어야 합니다.
// ✅ 올바른 예시
void f1(int a, int b = 2, int c = 3);
void f2(int a = 1, int b = 2, int c = 3);
// ❌ 잘못된 예시
// void f3(int a = 1, int b, int c = 3); // 에러: b가 중간에 기본값 없음
// void f4(int a = 1, int b, int c); // 에러: a만 기본값
- 선언에만 기본값: 헤더 파일의 선언에만 기본값을 지정하며, 구현 파일에는 생략합니다.
// header.h
void func(int x, int y = 20);
// source.cpp
void func(int x, int y) { // 기본값 없음
std::cout << x << ", " << y << '\n';
}
- 재선언 시 기본값 추가 가능: 같은 매개변수에 대해 재선언 시 기본값을 추가할 수 있지만, 중복 지정은 불가합니다.
void func(int x, int y, int z); // 선언 1
void func(int x, int y, int z = 30); // 선언 2: z에 기본값 추가
void func(int x, int y = 20, int z); // 선언 3: y에 기본값 추가
// 최종: func(int x, int y = 20, int z = 30)
// ❌ 중복 지정
// void func(int x, int y = 20, int z = 30); // 에러: y, z 중복
가상 함수에서의 제약과 함정
기본 인자는 가상이 아닙니다. 가상 함수 호출에서도 어떤 기본값이 쓰이는지는 컴파일 타임에, 호출에 사용한 정적 타입으로 정해집니다.
// 타입 정의
struct Base {
virtual void f(int x = 1) { std::cout << "B " << x << '\n'; }
};
struct Derived : Base {
void f(int x = 2) override { std::cout << "D " << x << '\n'; }
};
int main() {
Derived d;
Base& b = d;
d.f(); // D 2 — 정적 타입 Derived → Derived의 기본값
b.f(); // D 1 — 정적 타입 Base → Base의 기본값(파생 쪽 기본값 아님!)
}
b.f()의 결과가 “D 1”인 이유는 두 결정이 서로 다른 시점에 이뤄지기 때문입니다. 어떤 함수 본문을 실행할지는 런타임에 vtable로 정해져 Derived::f가 선택되지만, 생략된 인자에 무엇을 넣을지는 컴파일 타임에 b의 선언 타입 Base&를 보고 정해져 1이 들어갑니다. 결과적으로 파생 클래스의 코드가 베이스 클래스의 기본값으로 실행되는, 어느 쪽 작성자도 의도하지 않은 조합이 됩니다. Scott Meyers의 “Effective C++” 항목 37이 “상속받은 기본 인자 값은 절대로 재정의하지 말라”고 권하는 이유이며, clang-tidy의 google-default-arguments 검사는 가상 함수에 기본 인자를 두는 것 자체를 경고합니다.
파생 클래스에서 override한 함수에 다른 기본값을 두는 것은 문법적으로 가능하지만, 베이스 포인터로 호출하는 사용자에게는 혼란만 줍니다. 공개 API에서는 기본값은 베이스 선언에만 두고 파생에서는 생략하거나, 아예 비가상 래퍼로 기본값을 처리하는 편이 안전합니다.
또한 기본 인자가 있는 가상 함수를 오버라이드할 때, 시그니처(이름·매개변수 목록)는 같아야 하므로 기본값 목록은 상속 규칙과 별개로 헤더 설계를 맞춰야 합니다.
함수 오버로딩과의 관계 (심화)
기본 인자는 “호출 시 인자 개수를 줄이는 문법 설탕”일 뿐, 별도의 오버로드가 생기는 것은 아닙니다. 그래서 다음이 성립합니다.
- 재정의 집합에 “기본값만 다른” 함수를 추가하면, 일부 호출이 모호해질 수 있습니다(위 “문제 3” 참고).
- 템플릿 부분 특수화나
enable_if와 섞을 때도, “이 호출이 어떤 함수로 갈지”가 한눈에 안 들어오면 기본 인자보다 명시적 오버로드가 나을 때가 많습니다. - 가변 인자 템플릿과는 보통 충돌하지 않지만,
f(int)와f(int, int = 0)가 같이 있으면f(0)에서 모호합니다.
실전 패턴 보강
- API 확장: 옵션 객체(
struct Options)나 빌더 패턴을 쓰면 기본 인자 나열이 길어지는 것을 피할 수 있습니다. 기본 인자는 2~3개까지가 읽기 좋은 경우가 많습니다. - 헤더 전방 선언: 기본값은 첫 선언(보통 헤더)에 모읍니다. 헤더를 포함한 구현 파일의 정의에 같은 기본값을 또 쓰면 “redefinition of default argument” 컴파일 에러가 납니다.
- 람다: 람다의 매개변수에도 기본 인자를 둘 수 있습니다(C++11 시절 명세상 모호했던 부분이 C++14에서 정리됨). 반면 함수 포인터나
std::function타입에는 기본 인자 정보가 담기지 않으므로, 이들을 통해 호출할 때는 모든 인자를 넘겨야 합니다.
흔한 실수 추가
- 기본값 표현식이 매 호출마다 평가: 기본 인자가
get_id()처럼 부작용이 있으면, 생략 호출마다 실행됩니다. “한 번만”이 필요하면 기본값은std::nullopt등으로 두고 본문에서 처리하세요. - 정적 바인딩: 위 가상 함수 예처럼, 인터페이스 문서에 “베이스 타입으로 호출 시 기본값은 베이스 기준”을 명시하는 것이 좋습니다.
- 다른 매개변수를 기본값에 쓸 수 없음:
void f(int x, int y = x);는 GCC에서local variable 'x' may not appear in this context에러가 납니다. 기본값이 평가되는 시점에 매개변수는 값으로 쓸 수 없기 때문이며,void f(int x) { f(x, x); }처럼 오버로드로 전달하는 방식으로 해결합니다. 멤버 함수의 기본값에this를 쓰는 것도 같은 이유로 금지됩니다.
생성자의 기본 인자와 암시적 변환
기본 인자는 생성자에서 특히 조심해야 합니다. 모든 매개변수에 기본값이 있는 생성자는 기본 생성자가 되고, 인자 하나로 호출할 수 있게 된 생성자는 변환 생성자가 됩니다.
class Buffer {
public:
Buffer(std::size_t size = 4096, bool zeroed = false);
};
void send(const Buffer& b);
Buffer a; // 기본 생성자: Buffer(4096, false)
send(64); // 컴파일됨: 64가 Buffer(64, false)로 조용히 변환
Buffer c = 128; // 복사 초기화도 통과
작성자는 “크기의 기본값”과 “0으로 채우기 옵션”을 원했을 뿐인데, 그 결과 모든 정수가 Buffer로 암시적 변환되는 문이 열렸습니다. 다른 오버로드를 부르려던 send(bytesWritten)이 임시 64바이트 버퍼를 만들어 넘기는 식의 버그가 여기서 나옵니다. 생성자에 explicit을 붙이면 Buffer a;와 Buffer b(64);는 그대로 동작하고 send(64)만 컴파일 에러가 됩니다. 두 번째 매개변수에 기본값을 추가하는 순간 이 생성자가 “인자 하나로 호출 가능한 생성자”가 된다는 점을 놓치기 쉬워서, 저는 생성자에 기본 인자를 추가하는 변경을 리뷰할 때 explicit 여부를 가장 먼저 확인합니다.
템플릿에서는 사용할 때만 인스턴스화
함수 템플릿의 기본 인자 표현식은 실제로 그 기본값을 쓰는 호출이 있을 때만 인스턴스화됩니다.
template <typename T>
void fill(std::vector<T>& v, const T& value = T{}) {
std::fill(v.begin(), v.end(), value);
}
struct NoDefault { explicit NoDefault(int) {} };
std::vector<NoDefault> v(3, NoDefault{1});
fill(v, NoDefault{2}); // OK: T{}는 인스턴스화되지 않음
// fill(v); // 여기서만 에러: NoDefault에 기본 생성자가 없음
덕분에 기본 생성자가 없는 타입에도 이 템플릿을 쓸 수 있지만, 반대로 기본값 표현식에 문제가 있어도 템플릿을 작성할 때는 드러나지 않고, 누군가 처음으로 인자를 생략해 호출할 때 헤더 안쪽을 가리키는 에러로 나타납니다. 템플릿 매개변수의 기본값(template <typename T = int>)은 이와 별개의 기능이며, 함수 인자가 아니라 추론되지 않은 템플릿 매개변수를 채우는 데 쓰입니다.
실전 예시
예시 1: 로그 함수
#include <iostream>
#include <fstream>
#include <chrono>
#include <ctime>
enum class LogLevel {
DEBUG,
INFO,
WARNING,
ERROR
};
void log(const std::string& message,
LogLevel level = LogLevel::INFO,
bool timestamp = true) {
if (timestamp) {
auto now = std::chrono::system_clock::now();
auto time = std::chrono::system_clock::to_time_t(now);
std::cout << std::ctime(&time) << " ";
}
switch (level) {
case LogLevel::DEBUG: std::cout << "[DEBUG] "; break;
case LogLevel::INFO: std::cout << "[INFO] "; break;
case LogLevel::WARNING: std::cout << "[WARNING] "; break;
case LogLevel::ERROR: std::cout << "[ERROR] "; break;
}
std::cout << message << std::endl;
}
int main() {
log("서버 시작");
log("경고 메시지", LogLevel::WARNING);
log("에러 발생", LogLevel::ERROR, false);
}
예시 2: 파일 읽기
#include <fstream>
#include <string>
std::string readFile(const std::string& filename,
size_t maxSize = 1024 * 1024,
bool binary = false) {
std::ios::openmode mode = std::ios::in;
if (binary) {
mode |= std::ios::binary;
}
std::ifstream file(filename, mode);
if (!file) {
throw std::runtime_error("파일 열기 실패");
}
std::string content;
content.reserve(maxSize);
std::string line;
while (std::getline(file, line) && content.size() < maxSize) {
content += line + "\n";
}
return content;
}
int main() {
auto text = readFile("test.txt");
auto data = readFile("data.bin", 2048, true);
}
예제 2처럼 기본값이 “최대 크기”를 뜻하는 경우에는 기본값이 곧 동작 정책이 된다는 점을 의식해야 합니다. 이 코드는 인자를 생략하면 파일 크기와 상관없이 1MB를 reserve하므로, 작은 설정 파일을 수백 개 읽는 코드에서는 불필요한 메모리 할당이 반복됩니다. 또 getline으로 줄 단위로 읽기 때문에 binary = true여도 개행 문자가 \n으로 정규화되어 바이너리 데이터가 깨질 수 있습니다. 기본 인자는 편하지만 호출하는 쪽에서 값이 보이지 않으므로, 이렇게 동작에 큰 영향을 주는 값일수록 이름 있는 상수(constexpr size_t kDefaultMaxSize)로 빼서 문서화해 두는 편이 좋습니다.
예시 3: 그리기 함수
struct Color {
int r, g, b;
};
void drawRectangle(int x, int y,
int width = 100,
int height = 100,
Color color = {0, 0, 0}) {
std::cout << "사각형 그리기:" << std::endl;
std::cout << " 위치: (" << x << ", " << y << ")" << std::endl;
std::cout << " 크기: " << width << "x" << height << std::endl;
std::cout << " 색상: RGB(" << color.r << ", "
<< color.g << ", " << color.b << ")" << std::endl;
}
int main() {
drawRectangle(10, 20);
drawRectangle(10, 20, 200);
drawRectangle(10, 20, 200, 150);
drawRectangle(10, 20, 200, 150, {255, 0, 0});
}
예시 4: HTTP 요청
#include <string>
#include <map>
class HttpClient {
public:
std::string request(const std::string& url,
const std::string& method = "GET",
const std::map<std::string, std::string>& headers = {},
const std::string& body = "",
int timeout = 30) {
std::cout << method << " " << url << std::endl;
std::cout << "Timeout: " << timeout << "s" << std::endl;
for (const auto& [key, value] : headers) {
std::cout << key << ": " << value << std::endl;
}
if (!body.empty()) {
std::cout << "Body: " << body << std::endl;
}
return "Response";
}
};
int main() {
HttpClient client;
client.request("https://api.example.com/users");
client.request("https://api.example.com/users", "POST",
{{"Content-Type", "application/json"}},
R"({"name": "Alice", "age": 30})");
}
헤더와 구현 분리
// myclass.h
class MyClass {
public:
void func(int x, int y = 20); // 기본값은 선언에만
};
// myclass.cpp
void MyClass::func(int x, int y) { // 정의에는 기본값 없음
std::cout << x << ", " << y << std::endl;
}
자주 발생하는 문제
문제 1: 순서 위반
// ❌ 중간에 기본값 없음
// void func(int x = 10, int y, int z = 30) { // 에러
// // ...
// }
// ✅ 오른쪽부터 기본값
void func(int x, int y = 20, int z = 30) {
// OK
}
문제 2: 재선언 시 기본값
// ❌ 재선언 시 기본값 중복
void func(int x = 10);
// void func(int x = 10) { // 에러: 기본값 중복
// // ...
// }
// ✅ 정의에는 기본값 없음
void func(int x) {
// OK
}
문제 3: 오버로딩과 충돌
// ❌ 모호함
void func(int x) {
std::cout << "1개 인자" << std::endl;
}
void func(int x, int y = 0) {
std::cout << "2개 인자" << std::endl;
}
int main() {
// func(10); // 에러: 모호함
func(10, 20); // OK
}
문제 4: 포인터 기본값
// ❌ 지역 변수 주소 (기본값은 함수 선언 위치의 스코프에서 해석됨)
void func(int* ptr = &localVar) { // 에러
// ...
}
// ✅ nullptr 또는 전역 변수
void func(int* ptr = nullptr) {
if (ptr) {
// ...
}
}
기본 인자 vs 오버로딩
// 기본 인자
void func(int x, int y = 20, int z = 30) {
std::cout << x << ", " << y << ", " << z << std::endl;
}
// 오버로딩
void func(int x) {
func(x, 20, 30);
}
void func(int x, int y) {
func(x, y, 30);
}
void func(int x, int y, int z) {
std::cout << x << ", " << y << ", " << z << std::endl;
}
비교표:
| 특징 | 기본 인자 | 오버로딩 |
|---|---|---|
| 코드 간결성 | ✅ 간결 (1개 함수) | ❌ 복잡 (여러 함수) |
| 구현 차이 | ❌ 불가 (같은 구현) | ✅ 가능 (다른 구현) |
| 타입 차이 | ❌ 불가 (같은 타입) | ✅ 가능 (다른 타입) |
| 유지보수 | ✅ 쉬움 | ⚠️ 중복 코드 |
| 바이너리 크기 | ✅ 작음 | ⚠️ 큼 (여러 함수) |
표의 “바이너리 크기”와 “유지보수” 항목은 조건부입니다. 오버로드 방식이라도 짧은 전달 함수(void connect(const std::string& h) { connect(h, 8080); })는 보통 인라인되어 크기 차이가 거의 없고, 반대로 기본 인자에는 표에 없는 비용이 있습니다. 기본값이 호출하는 쪽의 기계어에 박힌다는 점입니다. 헤더의 기본값을 8080에서 9090으로 바꿔도, 새 헤더로 다시 컴파일되지 않은 오브젝트 파일이나 이미 배포된 바이너리는 여전히 8080을 넘깁니다. 기본값을 라이브러리 쪽에서 바꿀 수 있어야 하는 공개 API라면 전달 오버로드가 더 안전하고, 항상 함께 다시 빌드되는 내부 코드라면 기본 인자가 더 간단합니다.
언제 무엇을 사용할까?:
// ✅ 기본 인자 사용
// - 같은 로직, 선택적 매개변수
void log(const std::string& msg, LogLevel level = INFO);
// ✅ 오버로딩 사용
// - 다른 타입, 다른 로직
void print(int x) { std::cout << x; }
void print(const std::string& s) { std::cout << s; }
// ✅ 오버로딩 사용
// - 다른 구현 필요
void process(int x) { /* 정수 처리 */ }
void process(const std::vector<int>& vec) { /* 벡터 처리 */ }
사용 권장사항
// ✅ 기본 인자 사용 권장
// 1. 선택적 매개변수
void connect(const std::string& host, int port = 8080);
// 2. 설정 옵션
void render(bool antialiasing = true, int quality = 100);
// 3. 하위 호환성
void func(int x, int y = 0); // 기존 코드 호환
// ❌ 기본 인자 지양
// 1. 복잡한 기본값
// 2. 순서가 중요한 경우
// 3. 여러 조합 필요 (오버로딩 사용)
실무 패턴
패턴 1: 설정 함수
class Server {
public:
void start(const std::string& host = "0.0.0.0",
int port = 8080,
int maxConnections = 100,
bool enableLogging = true) {
std::cout << "서버 시작: " << host << ":" << port << '\n';
std::cout << "최대 연결: " << maxConnections << '\n';
std::cout << "로깅: " << (enableLogging ? "활성" : "비활성") << '\n';
}
};
// 사용
Server server;
server.start(); // 모두 기본값
server.start("localhost"); // host만 변경
server.start("localhost", 3000); // host, port 변경
패턴 2: 빌더 대체
class HttpRequest {
public:
std::string send(const std::string& url,
const std::string& method = "GET",
const std::string& body = "",
int timeout = 30,
bool followRedirects = true) {
// HTTP 요청 로직
return "Response";
}
};
// 사용
HttpRequest req;
req.send("https://api.example.com/users");
req.send("https://api.example.com/users", "POST", R"({"name":"Alice"})");
패턴 3: 하위 호환성
// 버전 1.0
void processData(const std::vector<int>& data) {
// 처리 로직
}
// 버전 2.0: 새 매개변수 추가 (하위 호환 유지)
void processData(const std::vector<int>& data,
bool parallel = false) {
if (parallel) {
// 병렬 처리
} else {
// 기존 처리
}
}
// 기존 코드는 그대로 작동
processData(myData); // OK
이 “하위 호환”은 소스 호환에 한정된다는 점을 알아야 합니다. 기존 호출 코드를 다시 컴파일하면 문제없이 동작하지만, 바이너리 수준에서는 processData(const vector<int>&)와 processData(const vector<int>&, bool)이 이름 맹글링 결과가 다른 별개의 함수입니다. 그래서 공유 라이브러리(.so, .dll)의 함수에 기본 인자로 매개변수를 추가하면, 예전 버전으로 빌드된 실행 파일은 사라진 옛 심볼을 찾다가 “undefined symbol” 에러로 실행조차 되지 않습니다. 저도 라이브러리 쪽 코드만 보고 “기본값을 줬으니 호환된다”고 판단했다가 이 문제를 겪은 적이 있는데, ABI를 유지해야 하는 라이브러리라면 기존 시그니처를 그대로 남기고 새 매개변수를 받는 오버로드를 따로 추가하는 편이 안전합니다.
또 매개변수가 늘어나면서 bool 인자가 여럿 붙기 시작하면 start("localhost", 3000, 100, false)처럼 호출부만 보고는 false가 무엇인지 알 수 없게 됩니다. 이런 경우에는 기본 인자를 계속 늘리기보다 struct ServerOptions { int port = 8080; bool enableLogging = true; };처럼 기본값을 멤버 초기화로 가진 옵션 구조체를 받고, C++20의 지정 초기화(start({.port = 3000}))로 필요한 필드만 지정하는 방식이 읽기 쉽습니다.
FAQ
Q1: 헤더의 기본값을 바꿨는데 일부 동작이 예전 값 그대로입니다. 왜 그런가요?
A: 기본값은 호출하는 쪽을 컴파일할 때 그 자리에 채워지기 때문입니다. 바뀐 헤더로 다시 컴파일되지 않은 오브젝트 파일이나, 그 헤더를 보고 미리 빌드된 다른 라이브러리는 예전 값을 계속 넘깁니다. 빌드 시스템의 의존성 추적이 헤더를 놓쳤거나 미리 빌드된 바이너리를 섞어 쓸 때 생기며, 깨끗하게 다시 빌드하면 해결됩니다.
Q2: 기본 인자 vs 오버로딩?
A:
- 기본 인자: 같은 로직, 선택적 매개변수 (간단). 기본값이 호출하는 쪽에 박히므로 항상 함께 빌드되는 코드에 적합
- 오버로딩: 다른 타입 또는 다른 로직, 또는 라이브러리 쪽에서 기본값을 바꿀 수 있어야 하는 공개 API
Q3: 기본값은 어디에 지정하나요?
A: 함수 선언에만 지정합니다. 정의(구현)에는 기본값을 생략합니다.
Q4: 기본값 순서는?
A: 오른쪽부터 연속적으로 지정해야 합니다. 중간에 기본값이 없는 매개변수가 있으면 안 됩니다.
Q5: 성능 영향은?
A: 없습니다. 기본 인자는 컴파일 타임에 처리되므로 런타임 오버헤드가 없습니다.
Q6: 기본값으로 함수 호출 결과를 사용할 수 있나요?
A: 가능하지만, 매번 호출됩니다.
int getDefault() {
std::cout << "getDefault 호출\n";
return 10;
}
void func(int x = getDefault()) {
std::cout << "x: " << x << '\n';
}
func(); // "getDefault 호출", "x: 10"
func(); // "getDefault 호출", "x: 10" (매번 호출)
Q7: 규칙을 정확히 확인하려면 어디를 보나요?
A: cppreference.com - Default arguments에 재선언 규칙, 가상 함수, 템플릿 관련 규칙이 예제와 함께 정리되어 있습니다.
기본 인자는 함수 매개변수에 기본값을 지정하여 선택적 매개변수를 간결하게 구현합니다.
같이 보면 좋은 글
- C++ 함수 오버로딩: 오버로드 해석 순서와 모호한 호출이 생기는 경우
- C++ 함수 | 처음 배우는 함수 만들기
- C++ std::function vs 함수 포인터
- C++ 이름 은닉
- C++ inline 함수