[C++] 04. 클래스의 완성

kkado·2023년 10월 13일
0

열혈 C++

목록 보기
4/16
post-thumbnail

💬 윤성우 님의 <열혈 C++ 프로그래밍> 책을 혼자 공부하며 배운 내용을 정리합니다. 글의 모든 내용은 책에서 발췌하였습니다.


정보은닉의 이해

클래스 내의 모든 멤버 변수를 public으로 선언하면 더 넓은 범위에서 사용이 가능하니까 private으로 선언했을 때보다 좋은 것 아닌가 라는 생각을 할 수 있다.

그러나 어떤 객체의 멤버 변수에 대해 무분별하게 접근하면 잘못된 값이 저장될 수 있다.

멤버 변수는 private으로 선언하고, 필요 시 해당 변수에 접근할 수 있는 public 멤버 함수를 만들어서 정보의 타당성을 검증할 수 있게 하는 것이 '정보은닉' 이며, 보다 좋은 클래스가 되기 위한 기본 조건이다.

const 함수

상수를 뜻하는 const 키워드가 함수에 붙으면 "이 함수 내에서는 멤버 변수에 저장된 값을 변경하지 않겠다" 라는 선언을 할 수 있다.

const 키워드가 선언된 함수 내에서 멤버 변수의 값을 변경하려고 하면 컴파일 에러를 발생시킨다. 이처럼 실수를 했을 때 컴파일러가 에러를 발생시키도록 하여 에러를 최소화할 수 있게 한다.

참조자를 배울 때도 비슷한 내용이 있었다. 어떤 함수의 매개변수로서 참조자를 받을 때 상수 참조자로 받게 되면 이 매개변수의 값을 변경하지 않겠다 는 뜻을 선언할 수 있었다.

그리고 const 함수는 또 한 가지의 기능이 더 있다. 다음 코드를 보면,

class SimpleClass 
{
private:
    int num;
public:
    void init(int n) {
        num = n;
    }

    int getNum() {
        return num;
    }

    void showNum() const {
        std::cout << getNum() << "\n";
    }
}

showNum 함수가 const로 선언이 되었고, 안에서 getNum 함수를 호출한다. getNum 함수는 const 함수는 아니지만 멤버 변수의 값을 변경하지 않는다. 그러나 이 코드는 컴파일 에러를 발생시킨다.

const 함수 내에서는 const가 아닌 함수의 호출이 불가능하다.

설사 그 함수가 멤버 변수를 변경하지 않는 함수라고 하더라도 그럴 가능성이 있는 함수이기 때문에 애초에 상수 함수에서 호출할 수 없도록 원천을 봉쇄한다고 생각하면 될 것 같다.

이와 비슷한 케이스가 한 가지 더 있다.

class SimpleClass 
{
private:
    int num;
public:
    void init(int n) {
        num = n;
    }

    int getNum() {
        return num;
    }
};

class LiveClass
{
private:
    int num;
public:
    void init(const SimpleClass &s) {
        num = s.getNum();
    }
};

LiveClass 클래스 내부에는 SimpleClass를 상수 참조자 인자로서 갖는 init 함수가 있다.
그런데 SimpleClassgetNum 함수는 const 함수가 아니다. 이와 같은 상황에서도 컴파일 에러를 발생시킨다.

왜냐하면 LiveClassinit에서 상수 참조자로 SimpleClass를 받고 있다는 것은 SimpleClass의 멤버 변수 값을 변경하지 않겠다는 의미를 담고 있는데, 정작 호출하는 SimpleClass 내의 함수는 상수 함수가 아니므로 이 함수에서 멤버 변수 값이 변경될 가능성이 있기 때문이다.

멤버 변수의 값을 변경하지 않는 함수를 만들 때 const를 붙이는 습관을 들인다면 컴파일 에러를 통해 멤버 변수의 값이 변경되는 실수를 막을 수 있다.


캡슐화

객체지향 프로그래밍 관점에서 캡슐화의 장점은 두 가지이다.

  • 비슷한 속성과 기능을 하나로 묶는다.
  • 데이터를 외부로부터 보호한다. (정보은닉)

만약 기침, 가래, 콧물이 생길 때 기침약, 가래약, 콧물약을 따로 먹는 것보다 세가지 증상을 모두 낫게 해주는 (세 가지 약이 하나의 약으로 '캡슐화' 된) '감기약' 을 먹는 것이 간편할 것이다.

class PillA {
public:
    void take() {
        std::cout << "take pill A" << "\n";
    }
};

class PillB {
public:
    void take() {
        std::cout << "take pill B" << "\n";
    }
};

class PillC {
public:
    void take() {
        std::cout << "take pill C" << "\n";
    }
};

class GoodPill {
private:
    PillA a;
    PillB b;
    PillC c;

public:
    void take() {
        a.take();
        b.take();
        c.take();
    }
};

캡슐화의 까다로운 점은 바로 캡슐화의 범위를 지정하기가 애매하다 라는 것이다.

만약 기침, 가래, 콧물에 효능이 있는 '감기약' 이 있다고 치면, 몸살은? 두통은? 발열은? 이 모두에 효능이 있는 약을 먹어야 하나? 쉽게 답할 수 없고 정답 또한 없다. 오랜 시간이 지나면 클래스를 캡슐화하는 능력이 길러지겠지...


생성자와 소멸자

생성자

클래스의 private 멤버 변수를 초기화하기 위하여 본 기능 함수를 실행하기 이전에 가령 init 와 같은 이름의 함수 호출을 통해 멤버 변수를 초기화하는 작업을 거쳐야 했다. 그러나 생성자를 사용하면 이러한 초기화 작업을 클래스 객체의 선언과 동시에 할 수 있다.

class SimpleClass
{
private:
    int num;
    
public:
    SimpleClass(int n)
    {
        num = n;
    }

    int getNum() const
    {
        return num;
    }
};

클래스의 이름과 동일하고, 앞에 반환형이 명시되어 있지 않은 이상하게 생긴 함수, SimpleClass(int n) ~~ 부분이 바로 이 클래스의 생성자(constructor) 이다.

생성자는 객체 생성시 딱 한 번 호출된다.

그리고 우리는 생성자를 정의했으므로, 앞으로 객체를 생성할 때 생성자의 형태에 맞게 인자를 전달해 주어야 한다. 따라서 위와 같은 코드에서 SimpleClass 객체는 다음과 같이 선언한다.

SimpleClass sc1(10);
SimpleClass sc2 = new SimpleClass(20);

생성자는 오버로딩이 가능하고, 매개변수에 디폴트 값을 지정할 수 있다.

따라서 다음과 같은 구현도 가능하다.

class SimpleClass
{
private:
    int num1;
    int num2;

public:
    SimpleClass()
    {
        num1 = 10;
        num2 = 20;
    }

    SimpleClass(int n1, int n2 = 20)
    {
        num1 = n1;
        num2 = n2;
    }
};

물론 함수에 오버로딩 및 디폴트 값 설정을 하듯이 여러 생성자에 대응되도록 구성하면 안 된다.

활용예

위에서 사용한 예제코드를 그대로 갖고온다.

class SimpleClass 
{
private:
    int num;
public:
    void init(int n) {
        num = n;
    }

    int getNum() {
        return num;
    }
};

생성자를 활용하면, 굳이 SimpleClass s; 선언 후 s.init(10) 와 같이 함수를 호출하지 않고 다음과 같이 구현할 수 있다.

class SimpleClass 
{
private:
    int num;
public:
    SimpleClass(int n) {
        num = n;
    }

    int getNum() {
        return num;
    }
};

멤버 이니셜라이저

만약 어떤 클래스 A가 멤버 변수로서 다른 클래스 B 객체를 가진다고 해보자.
생성자를 몰랐을 때는 먼저 B 객체를 선언하고, A 객체의 함수 파라미터로 전달하여 초기화시켜주는 방식을 사용했다.

멤버 이니셜라이저를 사용하면 성자를 사용하면 A 클래스 생성자에서 B 클래스의 생성자 호출도 가능하다.

점을 나타내는 Point 클래스가 있고, 직사각형을 의미하는 Rectangle 클래스가 있는데 Rectangle 클래스는 2개의 Point 객체를 멤버 변수로서 가진다고 가정하자.

그러면 Rectangle 클래스의 생성자를 다음과 같이 구현하면 된다.

class Point
{
private:
    int xPos;
    int yPos;

public:
    Point(int x, int y)
    {
        xPos = x;
        yPos = y;
    }
};

class Rectangle
{
private:
    Point upLeft;
    Point lowRight;

public:
    Rectangle(const int &x1, const int &y1, const int &x2, const int &y2)
    : upLeft(x1, y1), lowRight(x2, y2)
    {
        // empty
    }
}

다음 부분이 멤버 이니셜라이저이다 :

: upLeft(x1, y1), lowRight(x2, y2)

이것이 의미하는 바는 다음과 같다.

Point 객체 upLeft의 생성 과정에서 x1, y1 두 인자를 받는 생성자를 호출한다.
Point 객체 lowRight의 생성 과정에서 x2, y2 두 인자를 받는 생성자를 호출한다.

우리는 이것으로 객체의 생성과정을 다음과 같이 정리할 수 있다.

  • 메모리 공간의 할당
  • 이니셜라이저를 이용한 멤버변수의 초기화
  • 생성자의 몸체 부분 실행

멤버 이니셜라이저는 객체 뿐만 아니라 멤버의 초기화에도 사용할 수 있다. 가령 이런 식이다.

class SoSimple
{
private:
    int num1;
    int num2;

public:
    SoSimple(int n1, int n2) : num1(n1)
    {
        num2 = n2;
    }
};

생성자에서 num1은 멤버 이니셜라이저를 통해 n1으로 초기화하고 있고 num2는 몸체 부분에서 일반적으로 초기화하고 있다.

이니셜라이저를 사용하면 선언과 동시에 초기화가 이루어지는 형태로 바이너리 코드가 생성되기 때문에, num2와 같은 방법보다는 num1과 같은 방법이 성능 측면에서 조금 더 효과적이다.

그리고 선언과 동시에 초기화가 이루어지는 사실로부터, const 멤버 변수도 이니셜라이저를 이용하면 초기화가 가능하다는 사실을 알 수 있다.

class SoSimple
{
private:
    const int CONST;

public:
    SoSimple(int n) : CONST(n)
    {
    }
};

const 변수와 마찬가지로, 참조자 역시 선언과 동시에 초기화가 이루어져야 하는 특징이 있다. 따라서 이니셜라이저를 이용하면 참조자 역시 멤버변수로 선언할 수 있다.


디폴트 생성자

생성자를 배우기 전 우리는 별도로 클래스를 정의할 때 생성자를 만들지 않았는데, 생성자는 클래스 객체가 생성될 때 무조건 한 번 실행된다는 특징이 있었다. 그럼 어떤 생성자가 실행된 것일까.

C++ 컴파일러는 생성자를 정의하지 않은 클래스에 인자가 없고 아무 일도 하지 않는 디폴트 생성자를 자동으로 생성한다.

따라서 다음과 같이 생성자가 없는 클래스를 정의하면 :

class AAA
{
private:
    int num;

public:
    int getNum()
    {
        return num;
    }
};

사실은 이러한 생성자를 가진 클래스와 완전히 동일하다.

class AAA
{
private:
    int num;

public:
    AAA() {}
    
    int getNum()
    {
        return num;
    }
};

그리고 사용자가 별도로 생성자를 정의하면 디폴트 생성자는 만들어지지 않는다.

그러므로 사용자가 직접 생성자를 정의해서 사용할 때에는 반드시 객체를 생성할 때 올바른 인자를 넘겨주어 초기화를 진행해야 한다. 그렇지 않으면 생성자 불일치가 발생한다.

private 생성자

지금까지 봤던 생성자들은 모두 public으로 선언이 되었다. 객체의 생성이 외부에서 이루어지기 때문에 생성자는 public으로 선언되어야 한다.

그러나, 클래스 내부에서만 객체를 생성한다면 생성자가 private 이어도 문제 없다.

class AAA
{
private:
    int num;

public:
    AAA() : num(0) {}

    AAA& CreateInitObj(int n) const
    {
        AAA* ptr = new AAA(n);
        return *ptr;
    }

    void Shownum() const
    {
        std::cout << num << "\n";
    }

private:
    AAA(int n) : num(n) {}
};

int main() {
    AAA base;
    base.Shownum();

    AAA &obj1 = base.CreateInitObj(3);
    obj1.Shownum();

    AAA &obj2 = base.CreateInitObj(12);
    obj2.Shownum();

    delete &obj1;
    delete &obj2;
}

AAA &obj1 = base.CreateInitObj(3); 를 보면 base 내의 CreateInitObj 함수에서 새로운 AAA 객체를 만들고 이를 AAA* 포인터로 가리켜 obj1이 클래스 참조자가 되게 하였다.

이전에 참조자에 대해 배울 때 참조자는 힙에 할당된 변수에 접근 가능하다고 하여 다음과 같은 코드를 본 적 있다.

int *ptr = new int;
int &ref = *ptr;
ref = 20;

이와 비슷하게 동작할 수 있다는 것!


소멸자

생성자가 객체 생성 시 반드시 호출되는 것이라면, 소멸자(destructor)는 반대로 객체가 소멸될 시 반드시 호출되는 것이다.

소멸자는 클래스 이름 앞에 ~가 붙고, 반환형도 없으며, 매개변수는 없다.(void) 매개변수가 없기 때문에 디폴트 값 생성이나 오버로딩이 불가능하다.

소멸자도 생성자와 마찬가지로 사용자가 임의로 작성하지 않으면 디폴트 소멸자가 생성된다. 따라서 어떤 클래스를 다음과 같이 정의하면 :

class AAA
{
}

다음과 같이 정의한 것과 다름없다.

class AAA
{
public:
	AAA () {}
    ~AAA () {}
}

소멸자는 생성자에서 할당된 리소스의 반환에 사용된다.

소멸자에서는 delete 연산자를 이용해서 생성자 내에서 할당한 메모리 공간을 소멸시킨다.

class Person
{
private:
    char* name;
    int age;

public:
    Person(char *myName, int myAge)
    {
        int len = strlen(myName) + 1;
        name = new char[len];
        strcpy(name, myName);
        age = myAge;
    }

    ~Person()
    {
        delete []name;
        std::cout << "destructor called" << "\n";
    }
};

클래스와 배열

객체 배열과 객체 포인터 배열은 C언어의 구조체 배열과 구조체 포인터 배열과 흡사하다.

여러 클래스 객체를 한 번에 만들고 싶다면 다음과 같이 만들 수 있다.

AAA arr[10]; // 또는
AAA *ptrArr = new AAA[10];

이렇게 배열을 선언하면 각각의 객체에 생성자 인자를 넘겨줄 수 없다. 따라서 배열의 형태로 객체를 생성할 시 반드시 인자가 없는 생성자가 반드시 있어야 한다. 그리고 객체 각각의 멤버 변수들을 내가 원하는 값으로 바꾸기 위해서는 가령 setInfo와 같은 함수를 호출하여 일일이 초기화해주는 작업을 거쳐야 한다.

Person arr[3];
arr[0].showInfo();

객체 포인터 변수도 비슷하다.

Person* parr[3];
arr[0]->showInfo();

delete parr[0];

this 포인터

멤버 함수 내에서는 객체 자신을 가리키는 포인터 this 를 사용할 수 있다.

class Simple
{
private:
    int num;

public:
    Simple(int n) : num(n)
    {
        std::cout << "num : " << num << "\n";
        std::cout << "address : " << this << "\n";
    }

    void showSimpleData()
    {
        std::cout << num << "\n";
    }

    Simple* getThisPointer()
    {
        return this;
    }
};

int main(void)
{
    Simple sim(100);
    Simple* ptr = sim.getThisPointer();
    std::cout << ptr << ", ";
    ptr->showSimpleData();
}

해당 객체를 반환해주기 때문에 sim을 통해 출력한 numaddress 값은 ptr을 통해 출력한 numaddress 값과 동일하다.

또한 this 포인터의 특징은 다음과 같은 것들이 있다.

  • this 포인터를 사용하면 클래스 내의 멤버 변수와 함수의 인자의 이름이 같을 때 생기는 문제를 해결할 수 있다.
  • this 포인터를 이용해서 객체가 자신을 참조하는 데 사용할 수 있는 참조 정보를 반환하도록 할 수 있다.

참조 정보를 반환한다는 것이 이해가 잘 안되니까 예시 코드를 보면

class SelfRef
{
private:
    int num;

public:
    SelfRef (int n) : num(n)
    {}

    SelfRef& adder(int n)
    {
        num += n;
        return *this;
    }

    SelfRef& showTwoNumber()
    {
        cout << num << "\n";
        return *this;
    }
};

int main(void)
{
    SelfRef o(3);
    SelfRef& ref = o.adder(4);

    o.showTwoNumber(); // 7
    ref.showTwoNumber(); // 7

    ref.adder(1).showTwoNumber().adder(2).showTwoNumber(); // 8 10
    // 실행결과 : 7 7 8 10
}

이와 같이 참조에 참조를 해가는 식으로도 사용이 가능하다.
근데 이렇게 누가 쓰지...

profile
베이비 게임 개발자

0개의 댓글