10 분 소요

모던 C++

개요

멤버 함수는 다음과 같은 것이 있습니다.

이중 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 정의하지 않으면, 이들을 사용할때 컴파일러가 암시적으로 정의합니다.(클래스의 암시적 정의 참고)

항목 내용
T() {} 기본 생성자
T(const T& other) {} 복사 생성자
~T() {} 소멸자
T& operator =(const T& other) {} 복사 대입 연산자
operator U() const {} 형변환 연산자 정의
T& operator +=(const T& other) {} 연산자 오버로딩
U f() {} 멤버 함수
U f() const {} 상수 멤버 함수
static U f() {} 정적 멤버 함수
virtual U f() {} 가상 함수
virtual U f() = 0; 순가상 함수

멤버 함수

클래스는 캡슐화를 위해 데이터(멤버 변수)와 이를 처리하는 함수(멤버 함수)로 응집합니다.

다음 예에서 Date클래스는 멤버 변수m_Year, m_Month, m_Day과 이를 처리하는 멤버 함수들로 구성되어 있는데요, m_Year, m_Month, m_Dayprivate여서 직접 접근할 수 없으며, Getter 함수Setter 함수를 통해 은닉되어 있습니다.

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
class Date {
    int m_Year;
    int m_Month;
    int m_Day;
public: 
    Date(int year, int month, int day) :
        m_Year(year),
        m_Month(month), 
        m_Day(day) {}

    // Getter/Setter
    int GetYear() const {return m_Year;} // 상수 멤버 함수
    int GetMonth() const {return m_Month;}
    int GetDay() const {return m_Day;}

    void SetYear(int val) {m_Year = val;} // 멤버 함수
    void SetMonth(int val) {m_Month = val;}
    void SetDay(int val) {m_Day = val;}

    // 내부적으로 전체 개월수를 계산하기 위해,
    // 데이터와 처리하는 함수를 응집하였습니다. 
    int CalcTotalMonth() const { // 상수 멤버 함수
        return m_Year * 12 + m_Month; 
    }
};
Date date(20, 2, 10); // 20년 2월 10일
EXPECT_TRUE(date.CalcTotalMonth() == 20 * 12 + 2); 

상수 멤버 함수

멤버 함수의 뒤에 const를 붙여 상수 멤버 함수를 만들 수 있습니다. 상수 멤버 함수상수성 계약에 따라 다음을 준수합니다.

  1. 멤버 변수를 수정하지 않습니다.

    1
    2
    3
    4
    5
    
     class T {
         int m_Val;
     public:
         void Func() const {m_Val = 10;} // (X) 컴파일 오류. const 함수는 멤버 변수를 수정할 수 없습니다.
     };
    
  2. 멤버 변수를 몰래 수정할 수 있는 포인터나 참조자를 리턴하지 않습니다.

    1
    2
    3
    4
    5
    
     class T {
         int m_Val;
     public:
         int* Func() const {return &m_Val;} // (X) 컴파일 오류. int* 리턴. const 함수는 const int*를 리턴해야 합니다.
     };
    
  3. 내부 구현시 상수 멤버 함수만을 호출합니다.

    1
    2
    3
    4
    5
    6
    
     class T {
         int m_Val;
     public:
         void Func() const { NonConstFunc();} // (X) 컴파일 오류. const 함수는 비 상수 멤버 함수 호출할 수 없습니다.
         void NonConstFunc() {}
     };
    
  4. 메모리를 수정하지 않기 때문에 예외가 발생하지 않습니다.

따라서 상수성을 잘 지키면, 예외에 안전하며, 상수성 계약에 의해 안전하게 코딩할 수 있습니다. 함수가 개체를 변경시키는지 아닌지 항상 분명하게 인지하고, 최대한 상수 멤버 함수로 작성하세요.

비 상수 멤버 함수의 비 상수성 전파

상수 멤버 함수가 될 수 있음에도 비 상수 멤버 함수로 작성한다면, 이를 사용하는 모든 함수나 개체들이 비 상수로 만들어져야 합니다. 비 상수성은 바이러스처럼 전파되니 상수 멤버 함수가 될 수 있다면 꼭 상수 멤버 함수로 만드세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
class T {
    int m_Val;
public:
    int GetVal() {return m_Val;} // (△) 비권장. 상수 멤버 함수인데 비 상수로 정의했습니다.
};

class U {
    T m_T;
public:
    // m_T.GetVal()은 비 상수 멤버 함수여서 상수 멤버 함수인 GetInnerVal()에서 호출할 수 없습니다.
    // 어쩔 수 없이 GetInnerVal()을 비 상수로 만들어야 컴파일 할 수 있습니다.
    int GetInnerVal() const {return m_T.GetVal();} // (X) 컴파일 오류. 
};

정적 멤버 함수

멤버 함수static을 사용하여 개체에 속하지 않는 정적 멤버 함수를 정의 할 수 있습니다.

1
2
3
4
5
6
7
8
class T {
public:
    static int f() {return 10;} // 정적 멤버 함수
};

EXPECT_TRUE(T::f() == 10); // (O) T의 정적 멤버 함수 호출
T obj;
EXPECT_TRUE(obj.f() == 10); // (△) 비권장. T의 정적 멤버 함수 호출. 되기는 합니다만 일반 멤버 함수 호출과 구분이 안되어 가독성이 떨어집니다.

가상 함수

virtual을 붙이면 가상 함수가 되며, 부모 개체의 포인터나 참조자로 자식 개체에서 재구현한 함수(이를 오버라이딩(overriding)이라 합니다.)에 접근할 수 있습니다.

다음 코드에서 일반 함수인 f()가상 함수v()Derived에서 재구현 했을때, 어떻게 동작하는지 테스트하였습니다.

동일한 개체를

  • Base 포인터로 일반 함수인 f()를 호출하면, Base::f()가 호출되고,
  • 자식 개체인 Derived 포인터로 호출하면, Derived::f()가 호출됩니다.

일관성이 없으므로 사용하지 말아야 합니다.

가상 함수인 경우는 Base 포인터 이던 Derived 포인터 이던, Derived::v()가 정상적으로 호출됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Base {
public:
    int f() {return 10;}
    virtual int v() {return 10;} // 가상 함수
};

class Derived : public Base {
public:
    int f() {return 20;} // (△) 비권장. Base의 동일한 이름의 비 가상 함수를 가림
    virtual int v() {return 20;} // (O) Base의 가상 함수 재구현(오버라이딩)  굳이 virtual을 붙일 필요는 없습니다.
};

Derived d;
Base* b = &d;

EXPECT_TRUE(b->f() == 10); // (△) 비권장. Base 개체를 이용하면 Base::f()가 호출됨 
EXPECT_TRUE(d.f() == 20); // (△) 비권장. Derived 개체를 이용하면 Derived::f()가 호출됨        
EXPECT_TRUE(static_cast<Base&>(d).f() == 10); // (△) 비권장. 가려진 Base::f() 함수를 호출

EXPECT_TRUE(b->v() == 20); // (O)가상 함수여서 Derived::v()가 호출됨
EXPECT_TRUE(d.v() == 20); // (O)가상 함수여서 Derived::v()가 호출됨

자식 개체에서 부모 개체의 함수를 오버라이딩 하려면,

  1. 부모 개체에서 virtual 함수로 선언해야 합니다.
  2. 자식 개체에서 함수명/인자 타입/상수 멤버 함수의 const/동적 예외 사양이 동일해야 합니다. 만일 다르다면, 오버라이딩 되지 않습니다. 컴파일러가 오류를 잘 감지하지 못하니 주의하시기 바랍니다.

(C++11~) override가 추가되어 가상 함수 오버라이딩의 코딩 규약이 좀더 단단해졌습니다. 또한, final이 추가되어 가상 함수를 더이상 오버라이딩 못하게 할 수 있습니다.

리턴값 변경

자식 개체에서 가상 함수 재구현시 리턴 타입은 바뀔 수도 있습니다. 부모 개체의 것과 같거나 상속 관계(공변, covariant)이면 됩니다.

1
2
3
4
5
6
7
8
9
10
class Base {
public:
    virtual Base* v() {return this;} // 가상 함수
};

class Derived : public Base {
public:
    virtual Derived* v() {return this;} // (O) Derived 는 Base와 상속 관계여서 가능
    // virtual int* v() {return NULL;} // (X) 컴파일 오류. 밑도 끝도 없는 타입은 안됨
};

가상 함수 테이블(Virtual Function Table, vTable)

부모 클래스에 가상 함수가 있다면, 내부적으로 해당 개체의 시작 주소에 가상 함수 테이블(가상 함수 포인터에 대한 배열)을 생성합니다. 컴파일러에 따라 다를 수도 있으나 대부분 8byte 입니다. 가상 함수 호출시에는 가상 함수 테이블을 참조하여 호출하게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
class Base {
public: 
    virtual void v1() {}
    virtual void v2() {}
    virtual void v3() {}
    void f();
};
class Derived : public Base {
    virtual void v2() {} // 오버라이딩
    virtual void v3() {} // 오버라이딩
};

상기와 같이 v2(), v3()오버라이딩 했다면, 가상 함수 테이블은 다음과 같이 구성됩니다.

  1. f()가상 함수가 아니므로 가상 함수 테이블에 포함되지 않습니다.
  2. v1()오버라이딩 하지 않았으므로, Derived가상 함수 테이블Base::v1()의 주소가 저장됩니다.
  3. v2(), v3()오버라이딩 되었으므로, Derived가상 함수 테이블Derived::v2(), Derived::v3()의 주소가 저장됩니다.

image

따라서,

1
2
3
Derived d;
Base* b = &d;
b->v3();

와 같이 Base 포인터로 가상 함수v3()을 호출하면, 다음 경로에 따라 Derived::v3() 이 호출됩니다.

image

가상 함수가 정의된 개체는 가상 함수 테이블의 추가 공간을 필요로 하므로, 불필요하게 가상 함수를 정의하면 메모리를 낭비하게 됩니다. 인터페이스가 필요하거나, 다형적 동작이 필요한 경우만 사용하시기 바랍니다.

순가상 함수

순가상 함수는 실제 구현없이 함수 규약만 정의할때 사용합니다. 순가상 함수가 있는 클래스는 인스턴스화 할 수 없으며, 반드시 상속해서 자식 개체에서 구현해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
class IEatable {
public:
    virtual void Eat() = 0; // 순가상 함수
};

class Dog : public IEatable {
public:        
    virtual void Eat() {} // 순가상 함수는 자식 개체에서 실제 구현을 해야 합니다.
};
IEatable eatable; // (X) 컴파일 오류. 순가상 함수가 있기 때문에 인스턴스화 할 수 없습니다.
Dog dog; // (O)

Getter 함수

개체의 멤버 변수를 리턴하는 함수를 특별히 Getter 함수라고 합니다. 다음 규칙에 맞춰 리턴 타입을 작성하는게 좋습니다.

  1. int 등 기본 자료형의 경우는 복사 부하가 참조 부하보다 적기 때문에 값을 리턴하는게 좋습니다.
  2. 클래스 등 복사 부하가 참조 부하보다 큰 개체는 참조자를 리턴하는게 좋습니다.
  3. 멤버 변수널 값이 되는 경우가 없기 때문에 포인터보다는 참조자로 리턴하는게 좋습니다.
  4. 멤버 변수를 수정하지 않는다면 상수 멤버 함수로 작성합니다.
  5. 값 타입을 리턴하는 경우는 어짜피 리턴값이 복제되므로, 리턴값const를 굳이 붙일 필요가 없습니다.
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
class T {};
class U {
    int m_Val1;
    T m_Val2;
public:
    int GetVal1() const {return m_Val1;} // (O) 멤버 변수의 값을 리턴
    int& GetVal1() {return m_Val1;} // (O) 멤버 변수의 값을 수정하는 참조자를 리턴    

    const T& GetVal2() const {return m_Val2;} // (O) 멤버 변수의 참조자 리턴
    T& GetVal2() {return m_Val2;} // (O) 멤버 변수의 값을 수정하는 참조자를 리턴  

    // const int GetVal1() const {return m_Val1;} // (△) 비권장. 리턴값이 기본 타입이라면 어짜피 리턴값이 복제되므로, `const` 부적절
    // int GetVal1() {return m_Val1;} // (△) 비권장. 멤버 변수를 수정하지 않으므로 상수 함수가 적절
    // const int GetVal1() {return m_Val1;} // (△) 비권장. 멤버 변수를 수정하지 않으므로 상수 함수가 적절

    // (△) 비권장. 포인터 보다는 참조자가 적절
    // const int* GetVal1() const {return &m_Val1;} // (△) 비권장. 포인터 보다는 참조자가 적절
    // int* GetVal1() {return &m_Val1;} // (△) 비권장. 포인터 보다는 참조자가 적절
    // int* GetVal1() const {return &m_Val1;} // (X) 컴파일 오류. 상수성 계약 위반
    // const int* GetVal1() {return &m_Val1;} // (△) 비권장. 상수 함수가 적절 

    // const int& GetVal1() const {return m_Val1;} // (△) 비권장. int는 기본 자료형이어서 참조 보다는 값 전달이 적절
    // int& GetVal1() const {return m_Val1;} // (X) 컴파일 오류. 상수성 계약 위반
    // const int& GetVal1() {return m_Val1;} // (△) 비권장. 상수 함수가 적절

    // (△) 비권장. 복사 부하가 참조 부하보다 큰 개체여서 값 복사 보다는 참조가 적절
    // const T GetVal2() const {return m_Val2;} // (△) 비권장. 값 복사 보다는 참조가 적절. 어짜피 리턴값이 복제되므로, `const` 부적절
    // T GetVal2() const {return m_Val2;} // (△) 비권장. 값 복사 보다는 참조가 적절
    // T GetVal2() {return m_Val2;} // (△) 비권장. 값 복사 보다는 참조가 적절. 멤버 변수를 수정하지 않으므로 상수 함수가 적절
    // const T GetVal2() {return m_Val2;} // (△) 비권장. 값 복사 보다는 참조가 적절. 어짜피 리턴값이 복제되므로, `const` 부적절, 멤버 변수를 수정하지 않으므로 상수 함수가 적절

    // (△) 비권장. 포인터 보다는 참조자가 적절
    // const T* GetVal2() const {return &m_Val2;} // (△) 비권장. 포인터 보다는 참조자가 적절
    // T* GetVal2() {return &m_Val2;} // (△) 비권장. 포인터 보다는 참조자가 적절
    // T* GetVal2() const {return &m_Val2;} // (X) 컴파일 오류. 상수성 계약 위반
    // const T* GetVal2() {return &m_Val2;} // (△) 비권장. 상수 함수가 적절

    // T& GetVal2() const {return m_Val2;} // (X) 컴파일 오류. 상수성 계약 위반
    // const T& GetVal2() {return m_Val2;} // (△) 비권장. 상수 함수가 적절
};

Setter 함수

개체의 멤버 변수를 설정하는 함수를 특별히 Setter 함수라고 합니다. 다음 규칙에 맞춰 함수 인자를 작성하는게 좋습니다.

  1. int 등 기본 자료형의 경우는 복사 부하가 참조 부하보다 적기 때문에 값을 전달하는게 좋습니다.
  2. 클래스 등 복사 부하가 참조 부하보다 큰 개체는 참조자를 전달하는게 좋습니다.
  3. 널검사가 최소화 될 수 있도록, 널 값이 되지 않는 경우라면 포인터보다는 참조자를 전달하는게 좋습니다.
  4. 값 타입을 전달하는 경우는 어짜피 인자에 복제되므로, 굳이 인자const를 붙일 필요가 없습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class T {};
class U {
    int m_Val1;
    T m_Val2;
public:
    void SetVal1(int val) {m_Val1 = val;} // (O) 멤버 변수에 값 대입
    void SetVal2(const T& val) {m_Val2 = val;} // (O) 참조자를 통해 전달받은 인자를 현 멤버 변수에 복사

    // void SetVal1(const int val) {m_Val1 = val1;} // (△) 비권장. 인자에 복사되므로 int와 const int는 동일 취급됨
    // void SetVal1(int* val) {m_Val = *val;} // (△) 비권장. 인자는 상수 타입이어야 함
    // void SetVal1(const int* val) {m_Val = *val;} // (△) 비권장. 포인터보다는 참조자가 좋음
    // void SetVal1(int& val) {m_Val = *val;} // (△) 비권장. 인자는 상수 타입이어야 함
    // void SetVal1(const int& val) {m_Val = *val;} // (△) 비권장. 기본 자료형의 경우 값 복사가 좋음

    // void SetVal2(T val) {m_Val2 = val;} // (△) 비권장. 값 복사 보다는 참조가 적절
    // void SetVal2(const T val) {m_Val2 = val;} // (△) 비권장. 인자에 복사되므로 T와 const T는 동일 취급됨

    // void SetVal2(T* val) {m_Val2 = *val;} // (△) 비권장. 인자는 상수 타입이어야 함
    // void SetVal2(const T* val) {m_Val2 = *val;} // (△) 비권장. 포인터보다는 참조자가 좋음
    // void SetVal2(T& val) {m_Val2 = val;} // (△) 비권장. 인자는 상수 타입이어야 함
};

댓글남기기