22 분 소요

모던 C++

개요

함수는 다음 목적을 위해 만듭니다.

  1. 함수 코드 재활용(코드 중복 제거)
  2. 함수 인자와의 타입에 기반한 코딩 계약
  3. 디버깅 편의성

함수 정의

함수 정의의 일반적인 형태는 하기와 같습니다.([]인 부분은 옵션입니다.)

1
리턴 타입 function_name(함수 인자 목록) [const] [throw(예외 목록)] {}
항목 내용
리턴 타입 함수 결과인 리턴값의 타입입니다. 배열은 사용할 수 없습니다.
함수 인자 목록 함수 인자 목록입니다.
const 멤버 함수인 경우 개체를 수정하지 않습니다.(상수 한정자(const), 변경 가능 지정자(mutable), 최적화 제한 한정자(volatile) 참고)
throw(예외 목록) 함수가 방출하는 동적 예외 사양입니다.
나열된 예외 이외에는 unexpected_handler 로 분기하는데요, 사용하지 마세요. 이유는 동적 예외 사양을 참고하시기 바랍니다.

(C++17~) noexcept가 함수 유형에 포함되어 예외 처리에 대한 코딩 계약을 좀더 단단하게 할 수 있습니다.

함수를 만드는데 있어서 리턴값함수 인자의 타입을 어떻게 하느냐에 따라서 불필요한 복사 부하를 최소화 하여 성능을 개선시킬 수 있으니, 리턴값함수 인자의 타입은 신중하게 결정해야 합니다.

함수 선언

다음에서 g()함수는 f()함수를 사용하고 있는데, 컴파일 시점에 f()함수에 대한 정보가 없으므로 컴파일 오류가 발생합니다.

1
2
int g(int param) {return f(param);} // (X) 컴파일 오류. f() 함수를 사용했지만, 나중에 정의되어 알지 못합니다.
int f(int param) {return param * 2;}  

따라서, f()함수의 정의 순서를 다음과 같이 변경해야 하는데요,

1
2
int f(int param) {return param * 2;}  // g() 함수보다 먼저 정의합니다.
int g(int param) {return f(param);} 

모든 함수들을 사용 순서에 따라 이렇게 재배치하는 것은 상당히 까다로운 일입니다.

또한 함수 정의가 별도의 cpp파일에 정의되어 있을 경우, 이를 #include 해서 사용해야 하는데, 여러개의 cpp 에서 사용해야 한다면, 여러개의 cpp 에서 #include 할 수도 없는 노릇입니다. 함수 정의가 중복되어 컴파일 오류가 발생하니까요.

함수 선언은 이러한 경우 어떠한 함수가 있다고 컴파일러에 미리 알려주는 역할을 합니다. 함수 정의는 중복되면 안되지만, 선언은 중복될 수 있기 때문에 함수 선언을 헤더 파일에 작성한 후 여러 cpp 에서 #include해도 됩니다.

함수 선언은,

  1. 함수 정의에서 본문({…} 부분입니다.)을 빼고 세미콜론(;)을 사용한 형태입니다.
  2. 함수 정의의 리턴 타입, 함수명, 인자 목록, const 여부, 동적 예외 사양이 동일해야 합니다.
  3. 함수 정의의 함수 인자명은 맞출 필요는 없습니다. 심지어 생략해도 됩니다. 하지만, 관례적으로 동일하게 합니다.
  4. 함수 선언만 하고 정의가 없다면 링크 시점에 오류가 발생합니다.

다음 예에서는 f()함수 선언을 상단에 미리 해놨기 때문에, g()함수를 f()함수보다 먼저 정의해도 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
int f(int param); // 함수 선언 
                  // 인자명을 꼭 동일하게 맞출 필요는 없습니다. 
                  // 관례적으로 맞출 뿐입니다. 
                  // 즉, int f(int); 와 같이 하셔도 됩니다.

int g(int param) {return f(param);} // (O) 선언만 된 함수 사용
int f(int param) {return param * 2;} // 함수 정의   

EXPECT_TRUE(g(10) == 20); 

함수 포인터

함수 포인터로 함수 자체를 변수처럼 사용할 수 있습니다.

다음 예에서 void (*p)(int);void를 리턴하고, int인자로 받는 함수 포인터 p를 선언합니다.

p = f; 또는 p = &f;함수 포인터 pf()함수를 가리키게 할 수 있고, p()(*p)()f()함수를 호출할 수 있습니다.

1
2
3
4
5
void (*p)(int); // void 를 리턴하고 int형을 인수로 전달받는 함수의 함수 포인터 p 선언

void f(int) {} // f 함수 정의
p = f; // 함수 포인터에 f 함수 대입. p = &f; 와 동일
p(10); // f 함수 실행. (*p)(10); 과 동일

타입 재정의(typdef)를 사용하면 좀더 변수스럽게 사용할 수 있습니다.

1
2
3
4
5
typedef void (*Func)(int); // void 를 리턴하고 int형을 인수로 전달받는 함수의 함수 포인터 타입 정의

Func p; // 함수 포인터 p 정의
p = f; // 함수 포인터에 f 함수 대입. p = &f; 와 동일
p(10); // f 함수 실행. (*p)(10); 과 동일

(C++11~) using을 이용한 타입 별칭이 추가되어 typedef 보다 좀 더 직관적인 표현이 가능해 졌습니다.

함수 포인터의존성 주입(의존성 역전 원칙 참고) 을 활용하여, 원하는 알고리즘으로 손쉽게 변경하거나, 제어의 역전 원칙에 따라 프레임워크에서 제어를 통제하고자 할때 사용할 수 있습니다.

예를 들어 계산기 프로그램을 만들기 위해 Button을 만든다고 가정해 봅시다. ButtonClick()시 버튼에 할당된 연산(더하기, 빼기)을 수행하는 역할만 하려고 합니다. 이럴 경우 연산 행위를 수행하는 함수를 함수 포인터Button에 전달하고, ButtonClick()함수 포인터를 호출해 주면 됩니다.

다음 예에서 plus() 함수와 minus() 함수를 Button 생성자에 전달하여 m_Func에 저장한 뒤, Click()시 이를 호출하여, Button에 따라 더하기나 빼기를 수행합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
typedef int (*Func)(int, int); // 함수 포인터 typedef

class Button {
private: 
    Func m_Func; // 버튼이 클릭될때 실행될 함수
public:
    explicit Button(Func func) : // 생성시 실행할 함수 포인터 전달
        m_Func(func) {}
    int Click(int a, int b) { 
        return m_Func(a, b); // 함수 포인터 실행
    }
};

int plus(int a, int b) {
    return a + b;
}
int minus(int a, int b) {
    return a - b;
}

// 버튼 생성시 어떤 연산을 수행할지 함수를 전달해 둡니다.
Button plusButton(plus); // 클릭시 더하기
Button minusButton(minus); // 클릭시 빼기

EXPECT_TRUE(plusButton.Click(10, 20) == 30); 
EXPECT_TRUE(minusButton.Click(10, 20) == -10); 

상기의 사례는 함수 포인터 대신 함수자Strategy 패턴을 이용하거나 의존성 주입(의존성 역전 원칙 참고) 을 활용해서도 구현 할 수 있습니다.

멤버 함수 포인터

개체의 멤버 함수함수 포인터로 접근 할 수 있습니다.

다음 예에서 typedef int (Data::*Func)() const;Data클래스의 상수 멤버 함수int를 리턴하고, 인자는 없는 함수 포인터 Func을 선언합니다.

Func func = &Data::Print; 와 같이 함수 포인터 FuncPrint()함수를 가리키게 할 수 있고, (data.*func)();와 같이 개체로부터 멤버 함수를 호출할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
class Data { 
public: 
    int Print() const {return 1;} 
};

typedef int (Data::*Func)() const; // Data 클래스 멤버 함수 typedef
Func func = &Data::Print;  // 멤버 함수 포인터 전달

Data data;
EXPECT_TRUE((data.*func)() == 1); // data 개체로 부터 멤버 함수 포인터 실행

예를 들어 인쇄 버튼이 상황에 따라 프린터에 출력하거나 화면 미리보기를 수행한다고 해봅시다. 동적으로 호출되는 멤버 함수가 달라지는데요, 멤버 함수 포인터를 전달해서 구현할 수 있습니다.

다음 예에서는 ButtonClick() 함수를 호출할때 m_Data의 어떤 멤버 함수를 실행할지 동적으로 전달하고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Data { 
public: 
    int Print() const {return 1;}
    int Preview() const {return 2;}
};
typedef int (Data::*Func)() const; // Data 클래스 멤버 함수 typedef

class Button {
private: 
    const Data& m_Data; // 버튼이 관리하는 Data
public:
    explicit Button(const Data& data) :
        m_Data(data) {}
    int Click(Func func) { 
        return (m_Data.*func)(); // 전달된 멤버 함수 포인터 실행
    }
};

Data data;
Button button(data);

EXPECT_TRUE(button.Click(&Data::Print) == 1); // data 개체로 부터 Print 함수 실행
EXPECT_TRUE(button.Click(&Data::Preview) == 2); // data 개체로 부터 Preview 함수 실행

리턴값

함수가 리턴문을 통해 전달하는 결과값을 리턴값이라 합니다.

void, 값 타입, 포인터, const 포인터, 참조자, const 참조자를 리턴할 수 있으며, 특별히 함수의 지역 변수참조자로 리턴하면 추후 오동작을 할 수 있으니 주의하시기 바랍니다.(Dangling 참조자 참고)

1
2
3
4
5
6
7
class T {
    int m_Val;

    void f() {} // 아무것도 리턴 안함
    int g() {return 0;} // 정수값을 리턴함
    const int& h() const {return m_Val;} // 멤버 변수의 참조자를 리턴함
};

리턴값의 타입은

  1. 값을 리턴할 것인지, 포인터를 리턴할 것인지, 참조자를 리턴할 것인지
  2. 상수를 리턴할 것인지, 비 상수를 리턴할 것인지

신중하게 결정해야 복사 부하를 줄이고, 타입에 기반한 코딩 계약 을 수립할 수 있습니다. 해당 방법에 대해서는 Getter 함수를 참고하시기 바랍니다.

리턴값 최적화(Return Value Optimization, RVO)

일반적으로 리턴값은 다른 변수에 복사 생성 혹은 복사 대입됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class T {
    int m_X;
    int m_Y;
public:
    // 값 생성자
    T(int x, int y) :
        m_X(x),
        m_Y(y) {
        std::cout << "RVO -> T::T()" << std::endl;
    }

    // 복사 생성자
    T(const T& other) {
        std::cout << "RVO -> T(const T& other)" << std::endl;    
    }
    
    T f() {
        T result(0, 0);
        return result;
    }
};
T t1(0, 0);
T t2(t1.f()); // T t2 = t1.f(); 와 동일

상기에서 T 개체는 t1생성시 1회, f()에서 result를 생성하면서 2회, f()에서 리턴한 값으로 t2복사 생성하면서 3회 생성될 것 같지만, 실제 확인해 보면 그렇지 않습니다.

GCC에서는 하기 2개만 실행됩니다. 복사 생성자는 호출되지도 않습니다.(컴파일러 최적화 방식에 따라 다를 수 있습니다.)

1
2
RVO -> T::T() // t1 생성
RVO -> T::T() // t2 생성

이는 리턴값resultt2에 전달되므로, 괜히 생성하고 전달하는게 아니라 리턴할 개체를 그냥 t2로 사용하기 때문입니다. 이를 리턴값 최적화(Return Value Optimization, RVO) 라고 합니다.

리턴값 최적화는 다음 조건에서 수행됩니다.

  1. 리턴값이 값 타입이어야 합니다.(참조자 타입이 아닙니다.)

  2. 리턴값 타입이 리턴하려는 개체와 같은 타입이어야 합니다.

상기의 f()함수는 이 두가지 조건을 모두 충족하기 때문에 컴파일러가 리턴값 최적화를 해줍니다.

1
2
3
4
T f() { // 리턴값은 T 로 값 타입입니다.
    T result(0, 0); // 리턴값과 리턴하려는 개체가 타입이 같습니다.
    return result;
}

또는 컴파일러가 최적화를 쉽게 할 수 있도록 리턴값을 명시적으로 변수로 만들기 보다는,

1
2
3
4
5
T result(0, 0);
.
. // 복잡한 제어문들. 
.
return result; // (△) 비권장. 컴파일러가 최적화를 못할 수도 있습니다.

다음처럼 임시 개체를 사용하는게 좋을 수 있습니다.

1
return result(0, 0); // (O) 임시 개체를 생성하는게 컴파일러가 최적화하기 편합니다.

(C++17~) 임시 구체화와 복사 생략 보증을 통해 컴파일러 의존적이었던 생성자 호출 및 함수 인수 전달 최적화, 리턴값 최적화등이 표준화 되었습니다.

값 타입 리턴값

함수의 리턴값을 이용하지 않고 인자를 통해 리턴값에 포인터나 참조자를 전달하여 함수의 결과값을 대입받을 수 있습니다만, 심각한 복사 부하가 있습니다.

다음 예에서는

  1. T Create()로 값 타입을 리턴하는 경우
  2. T* CreatePtr()로 포인터 타입을 리턴하는 경우
  3. Create(T* ptr)인자를 통해 리턴값을 대입 받는 경우
  4. Create(T& ref)인자를 통해 리턴값을 대입 받는 경우

를 비교한 예입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class T {
    int m_X;
    int m_Y;
public:
    T() {
        std::cout << "T : Default Constructor" << std::endl;
    }
    // 값 생성자
    T(int x, int y) :
        m_X(x),
        m_Y(y) {
        std::cout << "T : Value Constructor" << std::endl;
    }

    // 복사 생성자
    T(const T& other) {
        std::cout << "T : Copy Constructor" << std::endl;    
    }
    // 복사 대입 연산자
    T& operator =(const T& other) {
        std::cout << "T : Assign" << std::endl;    
        return *this;
    }    
    
    // 값 타입으로 생성합니다. RVO 가 적용됩니다.
    static T Create() { 
        T result(0, 0);
        return result;
    }

    // 포인터를 생성해서 리턴합니다. 호출한 곳에서 delete 해줘야 합니다.
    static T* CreatePtr() {
        return new T(0, 0);
    }

    // 생성된 개체에 복사 대입 합니다. 포인터라서 널검사가 필요합니다. NULL 이면 예외를 방출합니다.
    static void Create(T* ptr) {
        if (ptr == NULL) {
            throw std::invalid_argument("NULL"); 
        }

        *ptr = T(0, 0);
    }

    // 생성된 개체에 복사 대입 합니다.
    static void Create(T& ref) {
        ref = T(0, 0);
    }
};

T a = T::Create(); // 리턴값 최적화로 복사하지 않습니다.

T* b = T::CreatePtr(); // (△) 비권장. 생성한 포인터만 복사합니다. 포인터이기 때문에 나중에 delete 해야 합니다.
delete b;

T c; 
T::Create(&c); // (△) 비권장. 생성 후 대입 받으면, 기본 생성자, 값 생성자, 복사 대입 연산자가 호출됩니다.

T d;
T::Create(d); // (△) 비권장. 생성 후 대입 받으면, 기본 생성자, 값 생성자, 복사 대입 연산자가 호출됩니다.

실행 결과는 다음과 같습니다.

  1. 리턴값을 값 타입으로 사용한 경우가 복사 부하도 가장 적고, 인터페이스도 깔끔합니다.

  2. 포인터 타입을 리턴하면 복사 부하는 없으나, delete의 부담이 있고,

  3. 포인터를 인자로 전달하는건 2회의 생성과 1회 대입이 있을 뿐만아니라 예외 방출까지 합니다.

  4. 참조자인자로 전달하는 것도 2회의 생성과 1회 대입이 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// T a = Create();
T : Value Constructor

// T* b = CreatePtr();
T : Value Constructor

// T c;
// Create(&c);
T : Default Constructor
T : Value Constructor
T : Assign

// T d;
// Create(d);
T : Default Constructor
T : Value Constructor
T : Assign

복사 부하도 없고 사용법도 깔끔하니, 리턴값은 값 타입을 사용하시기 바랍니다.

단, 클래스 멤버 변수인 경우 참조자를 리턴하셔야 합니다. Getter 함수를 참고하세요.

인자(매개변수, Parameter)

리턴값의 타입과 마찬가지로, 인자 타입은

  1. 값을 전달받을 것인지, 포인터를 전달받을 것인지, 참조자를 전달받을 것인지
  2. 상수 개체를 전달받을 것인지, 비 상수를 전달받을 것인지

신중하게 결정해야 복사 부하를 줄이고, 타입에 기반한 코딩 계약 을 수립할 수 있습니다. 해당 방법에 대해서는 Setter 함수를 참고하시기 바랍니다.

명명된 인자 선언

함수의 인자는 타입과 이름을 지정하여 선언합니다.

1
void f(int a, int b); // (O)

이름이 없는 인자

함수 정의부에서 사용하지 않는다면 인자명을 생략할 수 있습니다.

인자명을 생략해도 무방한 경우는 다음 2가지 경우입니다.

  1. 함수 포인터 선언 용도라면 생략할 수 있습니다.

    1
    
     void (*f)(int, int); // (O)
    
  2. 함수 템플릿 구현시 함수 오버로딩을 위한 더미(Dummy) 개체를 전달할때 생략할 수 있습니다.(타입 처리 방법 공통화 참고)

이것 외에는 사용하지 않는 인자를 억지로 작성한 잘못된 설계입니다. 인터페이스 분리 원칙 위반이므로, 설계 변경을 추천합니다. 그럼에도 불구하고 꼭 인자 생략을 해야 한다면, 하기 작성 방법을 고려해 보세요.

다음은 함수 본문에서 컴파일러 경고를 막기 위해 억지로 사용한 건데요, 명시적으로 인자명을 적었기 때문에 컴파일러 최적화가 힘들수 있고요,

1
2
3
4
5
void f(int a, int b) {
    // 명시적으로 사용한 것이기에 컴파일러가 최적화 하기 힘듭니다.
    a; // (△) 사용하지 않는 인자에 대한 warning 제거. 
    b; // (△) 사용하지 않는 인자에 대한 warning 제거. 
}

다음은 인자명이 없어 컴파일러 최적화의 여지는 있으나, 어떤 인자가 필요한 건지 가독성이 좀 떨어집니다.(함수 오버로딩 동작 테스트 코드에서는 유용하죠. 괜히 억지로 인자명을 적으면 눈만 어지럽게 만드니까요.)

1
2
3
void f(int, int) {
    // (△) 비권장. 어떤 인자가 필요했던 것인지 알기 힘듭니다.
}

따라서, 컴파일러 최적화 여지도 있고, 가독성도 있도록 인자명만 주석으로 만드는 걸 추천합니다.

1
2
3
void f(int /*a*/, int /*b*/) {
    // (O) 인자명이 없어 컴파일러가 최적화 할 수 있습니다.
}

(C++17~) [[maybe_unused]]가 추가되어 사용되지 않은 개체의 컴파일 경고를 차단할 수 있습니다.

인자 없음

인자가 없으면 생략하거나 void를 작성할 수 있습니다.

1
2
void f(); // (O)
void f(void); // (O)

void를 다른 인자와 함께 섞어 쓰거나, const와 함께 쓰는건 컴파일 오류가 납니다.(void*는 포인터형이기에 void*const void*는 괜찮습니다.)

1
2
3
4
int f(void, int); // (X) 컴파일 오류
int f(const void); // (X) 컴파일 오류
int f(void*); // (O)
int f(const void*); // (O)

(C++20~) 축약된 함수 템플릿이 추가되어 함수 인자auto를 사용할 수 있습니다. 사실상 함수 템플릿의 간략한 표현입니다.

가변 인자(…)

인자가 1개 이상인 경우 ...으로 가변 인자를 처리할 수 있습니다.

항목 내용
va_start() 가변 인자 액세스 시작 매크로 함수
va_arg() 가변 인자 추출 매크로 함수
va_end() 가변 인자 사용 종료 매크로 함수
va_list 가변 인자에 대한 typedef

가변 인자를 사용하려면 <cstdarg>#include해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <cstdarg>
 
class T {
public:
    static int Sum(int count, ...) {
        int result = 0;
        std::va_list paramList; // 가변 인자
        va_start(paramList, count); // 가변 인자 처리 시작
        for (int i = 0; i < count; ++i) {
            result += va_arg(paramList, int); // 가변 인자 추출
        }
        va_end(paramList); // 가변 인자 처리 끝
        return result;       
    }
};
EXPECT_TRUE(T::Sum(3, 1, 2, 3) == 1 + 2 + 3);

(C++11~) 가변 템플릿파라메터 팩이 추가되어, 가변 인자(…)와 같이 갯수와 타입이 정해 지지 않은 템플릿 인자를 사용할 수 있습니다.
(C++11~) 가변 인자를 활용한 가변 매크로가 추가되어 C언어와의 호환성이 높아졌습니다.
(C++20~) __VA_OPT__가 추가되어 가변 인자가 있을 경우에는 괄호 안의 값으로 치환하고, 없을 경우에는 그냥 비워둡니다.

기본값 인자

함수 선언시 인자기본값을 줄 수 있습니다.

1
2
3
4
5
6
int f1(int a = 10) {  // 인수를 전달하지 않으면 기본값 10
    return a;
}

EXPECT_TRUE(f1() == 10); // 아무값도 주지 않으면 a 는 10
EXPECT_TRUE(f1(20) == 20);

기본값 인자를 사용하면, 그 뒤로는 다 기본값을 사용해야 합니다.(...은 가능합니다. int g(int n = 0, ...);)

1
2
int f2(int a, int b = 20, int c = 30); // (O)
int f2(int a, int b = 20, int c); // (X) 컴파일 오류. c에도 기본값을 줘야 합니다.

함수 선언과 정의가 분리되어 있으면, 선언부에만 기본값을 작성해야 합니다.

1
2
3
4
5
6
7
// 선언부
int f2(int a, int b = 20, int c = 30); // (O)

// 정의부
int f2(int a, int b /*= 20*/, int c /*= 30*/) { // (O) 선언과 정의 분리시, 정의부는 기본값 작성 안함.
    return 0;
}

this 포인터기본값으로 사용할 수 없습니다.

1
2
3
class T {
    void f3(T* p = this) {} // (X) 컴파일 오류. this는 기본값을 사용할 수 없음
};

전역 변수기본값으로 사용하면, 런타임 호출 시점의 값을 반영합니다.

1
2
3
4
5
6
7
8
int g_Val = 10;
int f4(int val = g_Val) { // g_Val의 런타임 호출 시점의 값이 사용됩니다.
    return val;
}
EXPECT_TRUE(g_Val == 10);
EXPECT_TRUE(f4() == 10);
g_Val = 20;
EXPECT_TRUE(f4() == 20);

전역 변수의 경우와 마찬가지로 함수 호출을 기본값으로 사용하면, 런타임 호출 시점의 값을 반영합니다.

1
2
3
4
5
6
7
8
9
int g_Val = 10;
int g() {return g_Val;}
int f5(int val = g()) { // g()의  런타임 호출 시점의 값이 사용됩니다.
    return val;
}
g_Val = 10;
EXPECT_TRUE(f5() == 10);
g_Val = 20;
EXPECT_TRUE(f5() == 20);

상속 관계에서 인자의 기본값을 재정의하면, 혼란을 야기할 수 있습니다.

다음과 같이 BaseDerived의 인스턴스를 생성하고 호출하면, 각자의 기본값대로 처리됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
class Base {
public:
    virtual int f6(int val = 10) const {
        return val;
    }
};
class Derived : public Base {
public:
    virtual int f6(int val = 20) const { // (△) 비권장. Base의 디폴트 값과 다르게 했습니다. 혼란스러워 질 수 있습니다.
        return val;
    }
};

실행 결과를 보면, Base 개체로 호출할 때와 Derived 개체로 호출할때 각자의 값으로 실행되는 걸 알 수 있습니다.

1
2
3
4
Base b;
Derived d;
EXPECT_TRUE(b.f6() == 10);   
EXPECT_TRUE(d.f6() == 20);    

하지만 Derived개체를 부모 개체인 Base* 통해 접근하면 가상 함수 테이블을 참조하여 호출하므로, Base기본값으로 처리됩니다.

1
2
3
Derived d;
Base* p = &d;
EXPECT_TRUE(p->f6() == 10); // 가상 함수 테이블을 참조하여 Base 의 기본값인 10을 사용합니다.   

이러한 차이가 생기므로, 부모 개체와 자식 개체의 기본값은 같은 값으로 하는게 좋습니다.

함수 오버로딩

C++언어에서는 이름이 동일한 함수를 여러개 정의하여 사용할 수 있습니다. 이렇게 함수명이 동일한 경우 전달한 인수와 전달받는 인자 타입에 따라 알맞은 함수를 호출하게 됩니다.

1
2
3
4
5
6
7
int f(int) {return 1;} //#1
int f(char) {return 2;} //#2
int f(long) {return 3;} // #3

EXPECT_TRUE(f(10) == 1); // int f(int) 호출
EXPECT_TRUE(f('a') == 2); // int f(char) 호출
EXPECT_TRUE(f(10L) == 3); // int f(long) 호출

오버로딩된 함수 결정 규칙

함수 오버로딩 시에는 다음과 같은 규칙에 맞춰 호출할 함수를 결정합니다.

암시적 형변환

기본적으로 인자 타입이 일치하는 것을 선택하지만, 해당 타입의 것이 없다면 암시적 형변환을 적용하여 최대한 매핑되는 것을 사용합니다.

1
2
3
4
5
6
7
int f(int) {return 1;}
int f(double) {return 2;}

EXPECT_TRUE(f(1) == 1); // (O) 타입 일치. int 버전 호출
EXPECT_TRUE(f(1.) == 2); // (O) 타입 일치. double 버전 호출
EXPECT_TRUE(t.f(1L) == 1); // (X) 컴파일 오류. long 버전이 없음
EXPECT_TRUE(f(1.F) == 2); // (△) 비권장. float 버전이 없지만, double로 암시적 형변환 되므로 double 버전 호출

동일 함수 취급

인자의 타입을 검사할때 동일 타입으로 취급하는 규칙이 있습니다.

  1. 배열은 포인터로 취급됩니다.

    1
    2
    
     int a[3] = {0, 1, 2};
     int* p = a;
    

    와 같이 포인터는 배열을 대입받을 수 있기 때문에 포인터를 대입받은 건지 배열을 대입받은 건지 구분할 수 없습니다. 따라서 동일 함수로 취급하며 다음과 같이 컴파일 오류가 발생합니다.

    1
    2
    3
    
     int f(int* a) {return 1;} 
     // int f(int a[3]) {return 2;} // (X) 컴파일 오류. 배열은 f(int* a)와 동일해서 오버로딩 안됨.
     // int f(int a[]) {return 3;} // (X) 컴파일 오류. 배열은 f(int* a)와 동일해서 오버로딩 안됨.
    
  2. 최상위 const인자 타입에서 제거하고 취급합니다.

    복사 대입시 최상위 const 제거 의 언급처럼 상수 개체복사 대입상수성이 제거될 수 있습니다.

    1
    2
    
     const int constVal = 10;
     int val = constVal; // 상수성이 제거되었습니다.
    

    와 같이 intconst int를 대입받을 수 있기 때문에 int를 대입받은 건지 const int를 대입받은 건지 구분할 수 없습니다. 따라서 동일 함수로 취급하며 다음과 같이 컴파일 오류가 발생합니다.

    1
    2
    
     int f(int a) {return 1;} 
     // int f(const int a) {return 2;} // (X) 컴파일 오류. int f(int) 와 동일해서 오버로딩 안됨.
    

    포인터나 참조자 관점에서 보면,

    1
    2
    
     int f(int* a);
     // int f(int* const a); // (X) 컴파일 오류
    

    가 동일합니다.

    하지만,

    1
    2
    
     int f(int* a);
     int f(const int* a);
    

    는 다릅니다. int* aa의 실제값을 수정할 수 있고, const int* a는 수정할 수 없으니까요.

함수 상수성 구분

함수의 상수성은 다른 타입으로 취급합니다.

1
2
3
4
5
6
7
8
9
10
11
class T {
public:
    int f() {return 1;}
    int f() const {return 2;} // (O) 함수 상수성에 따라 선택됨 
};

T val;
const T constVal = val;

EXPECT_TRUE(val.f() == 1);
EXPECT_TRUE(constVal.f() == 2);

리턴 타입 무시

리턴 타입은 함수 오버로딩을 결정하는데 사용하지 않습니다.

1
2
int f(int) {return 1;}
// double f(int) {return 2;} // (X) 컴파일 오류. 리턴 타입은 오버로딩 함수 결정에 사용하지 않음

는 리턴 타입은 다르지만, 인자가 같으니, 동일한 함수가 새롭게 정의되어 컴파일 오류가 발생합니다.

값 타입과 참조자간의 모호성

int, int&은 모두 const int&에 대입됩니다. 수정할 수 있는 것이지만, 수정하지 않고 사용하겠다는 의미여서 위험하지 않죠.

1
2
3
4
5
6
7
8
9
int f(const int& a) {return 1;} //int 타입, int& 타입, const int& 타입을 모두 받을 수 있습니다.

int val = 10;
int& ref = val;
const int& constRef = val;

EXPECT_TRUE(f(val) == 1); // int를 const int&에 대입하여 호출합니다.
EXPECT_TRUE(f(ref) == 1); // int&를 const int&에 대입하여 호출합니다.
EXPECT_TRUE(f(constRef) == 1); 

하지만, const int&int&로 변경하는건 위험합니다. 수정할 수 없는 것을 수정하겠다는 의미이니까요. 그래서 대입할 수 없습니다.

1
2
3
4
5
6
7
8
9
int f(int& a) {return 1;} //int 타입, int& 타입을 받을 수 있습니다.

int val = 10;
int& ref = val;
const int& constRef = val;

EXPECT_TRUE(f(val) == 1); // int를 int&에 대입하여 호출합니다.
EXPECT_TRUE(f(ref) == 1); 
// EXPECT_TRUE(f(constRef) == 1); // (X) 컴파일 오류. const int&는 int&에 대입되지 않습니다.        

f(const int& a)f(int& a) 모두 intint&를 호출하는데요, 둘을 함수 오버로딩해보면, 상수성을 유지하여 잘 선택해서 호출해줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int f(int& a) {return 1;} // int 타입, int& 타입을 받을 수 있습니다.
int f(const int& a) {return 2;} // const int 타입, const int& 타입을 받을 수 있습니다.  

int val = 10;
const int constVal = 10;

int& ref = val;
const int& constRef = val;

EXPECT_TRUE(f(val) == 1); // int를 int&에 대입하여 호출합니다.
EXPECT_TRUE(f(constVal) == 2); // const int를 const int&에 대입하여 호출합니다.

EXPECT_TRUE(f(ref) == 1); 
EXPECT_TRUE(f(constRef) == 2);

하지만 값 타입과 함께 사용하면 모호성 오류가 발생합니다. int, const int, int&, const int& 모두 int에 대입되니까요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int f(int a) {return 1;} // int 타입, const int 타입, int& 타입, const int& 타입을 받을 수 있습니다.
int f(int& a) {return 2;} // int 타입, int& 타입을 받을 수 있습니다.
int f(const int& a) {return 3;} // const int 타입, const int& 타입을 받을 수 있습니다. 

int val = 10;
const int constVal = 10;

int& ref = val;
const int& constRef = val;

// EXPECT_TRUE(f(val) == 1); // (X) 컴파일 오류. f(int)와 f(int&)와 f(const int&)가 모호합니다.
// EXPECT_TRUE(f(constVal) == 1); // (X) 컴파일 오류. f(int)와 f(const int&)가 모호합니다.

// EXPECT_TRUE(f(ref) == 2); // (X) 컴파일 오류. f(int)와 f(int&)와 f(const int&)가 모호합니다.
// EXPECT_TRUE(f(constRef) == 3); // (X) 컴파일 오류. f(int)와 f(const int&)가 모호합니다.

함수 인자의 유효 범위 탐색 규칙(Argument-dependent lookup, ADL)

오버로딩한 함수를 탐색할때 해당 함수 인자네임스페이스에서도 동일한 이름의 함수를 탐색한다는 규칙입니다. Argument-dependent lookup(ADL) 또는 Koenig 검색이라고 합니다.

구체적인 사례는 오버로딩 함수 탐색 규칙을 참고하세요.

오버로딩 함수 탐색 규칙

함수 오버로딩의 후보군은 하기 단계에 따라 수집되고 선정됩니다.

  1. 자신의 유효 범위에서 탐색

  2. 함수 인자의 유효 범위에서 탐색(Argument-dependent lookup, ADL 또는 Koenig 검색)

  3. 암시적 형변환을 포함하여 실행 가능 함수 결정

    1. 타입 완전 일치
    2. 경미한 암시적 형변환
    3. 승격 변환(boolint로, charint로, intdouble로 변환 등)

      1
      2
      3
      4
      5
      6
      7
      
        int f(int a) {return 1;}
        int g(int a) {return 2;}
        int h(double a) {return 3;}
      
        EXPECT_TRUE(f((bool)true) == 1); // bool은 int로 승격
        EXPECT_TRUE(g((char)'a') == 2); // char는 int로 승격
        EXPECT_TRUE(h((int)1) == 3); // int는 double로 승격    
      
    4. 표준 변환(자식 개체 포인터를 부모 개체 포인터로, T*void*로, intdouble로, doubleint)
    5. 사용자 정의 형변환(형변환 연산자 정의 참고)

다음 경우를 보면 g() 에서 MyFunc()을 호출하면, 같은 네임스페이스에서 MyFunc(double)을 찾고, 인자 1double로 암시적으로 변환해서 사용하게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace A {
    class Date {};
    int MyFunc(int) {return 1;}
} 
namespace B { 
    int MyFunc(double) {return 2;}
    int g() {
        A::Date d;
        // 현 유효 범위에서 MyFunc(double)을 찾음. 1을 double로 암시적으로 변환함
        return MyFunc(1); 
    }
}

EXPECT_TRUE(B::g() == 2); // B::MyFunc 이 채택됨

하지만 MyFunc()함수의 인자가 다음처럼 특정 네임스페이스의 것을 참조한다면,

1
int MyFunc(const C::Date&, double) {return 2;}

함수 인자의 유효 범위에서 탐색(Argument-dependent lookup, ADL 또는 Koenig 검색)에 의해 네임스페이스 C의 함수들에서도 검색하게 됩니다.

그래서 함수 후보군은

이 찾아지게 되고, 타입이 완전 일치하는 네임스페이스 Cint MyFunc(const Date&, int)이 사용됩니다.

좀더 넓은 범위에서 타입이 일치하는 함수를 찾도록 도와주는 역할입니다만, 의도한 동작일 수도 있고, 아닐 수도 있고, 분석이 어려워질 수도 있으니, 원리를 이해하고 사용하시기 바랍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace C {
    class Date {};
    int MyFunc(const Date&, int) {return 1;}
} 
namespace D { 
    int MyFunc(const C::Date&, double) {return 2;}
    int g() {
        C::Date d;
        // Koenig 검색이 활용됨. 
        // 인자로 전달한 d 가 네임스페이스 C에 있기 때문에, C의 함수들중 MyFunc(const Date&, int)를 찾아냄
        // MyFunc(const Date&, int) 과 MyFunc(const C::Date&, double) 중 타입이 일치하는 MyFunc(const Date&, int)이 채택됨
        return MyFunc(d, 1); 
    }  
}

EXPECT_TRUE(D::g() == 1); // C::MyFunc 이 채택됨

평가 순서

코드를 위에서부터 아래로 읽고, 왼쪽에서 오른쪽으로 읽기 때문에 “읽는 순서대로 실행될 것이다” 예측할 수 있는데요,

보통의 경우엔 그렇습니다만, 공식적으로는 연산자 우선 순위를 제외한 모든 실행 순서는 컴파일러 마음입니다.

심지어 컴파일러가 속도 최적화를 위해 캐쉬된 내용을 사용하려고 임의로 순서를 바꿀 수도 있습니다.(순차적 일관성 참고)

다음은 a(a_sub()), b(), c() 함수의 결과를 f() 에 전달하는 예인데요, a->b->c의 순서로 실행된다고 보증하지 않습니다.

  1. a->b->c,
  2. a->c->b,
  3. b->a->c,
  4. b->c->a,
  5. c->a->b,
  6. c->b->a

의 6가지 경우의 수중 하나로 실행됩니다. 이점 유의해야 예외 보증(스택 풀기 참고)과 쓰레드 프로그래밍(쓰레드 참고)을 할 수 있습니다.

a() 함수는 a_sub()의 결과를 인자로 받으므로, a_sub()보다 나중에 실행됨을 보증합니다.(예를 들어 b->a_sub->c->a가 될 수도 있습니다.)

1
f(a(a_sub()), b(), c());

댓글남기기