[CPP-00] 객체지향의 관점으로 클래스 이해하기

My Awesome Phonebook 과제를 통해 객체지향으로 프로그램을 설계하는 방법, 생성자로 인스턴스를 생성하는 방법, 접근제어 지시자와 const 함수로 좋은 클래스를 정의하는 방법을 학습했다.

[CPP Module00 / EX01: My Awesome Phonebook 과제 소개]

The user is prompted for input: you should accept the ADD command, the SEARCH command or the EXIT command. Any other input is discarded

  • 프로그램은 연락처가 없는 비어있는 상태에서 시작되며, 동적할당은 허용되지 않는다.

  • 최대 8개의 연락처를 저장할 수 있다. 아홉 번째 번호를 저장하려고 할 때 행동을 정의해야한다.

  • 명령이 올바르게 실행되었다면, 다시 프로그램은 ADD or SEARCH or EXIT 명령의 입력을 기다린다.

  • EXIT command

    • 프로그램은 종료되며 연락처 정보는 영원히 사라진다.

  • ADD command

    • 새 연락처의 모든 필드가 채워질 때 까지 사용자에게 정보를 받는다.

    • 연락처는 다음 11개의 필드로 구성된다.

      1. first name

      2. last name

      3. nickname

      4. login

      5. postal address

      6. email address

      7. phone number

      8. birthday date

      9. favorite meal

      10. underwear color (?)

      11. darkest secret

    • 연락처는 반드시 클래스의 인스턴스로 만들어져야한다.

  • SEARCH command

    • 모든 연락처의 리스트를 출력한다. 디스플레이는 다음 4개의 열(column)으로 구성되어있다.

      • index, first name, last name, nickname

    • 각 열의 너비는 최대 10글자이며, 그 이상의 문자는 자른 뒤 10번째 출력을 dot(.)으로 대체한다. 오른쪽 정렬한다.

    • 그 후, 사용자에게 특정 인덱스 연락처를 묻는 메시지를 표시하고, 입력을 받으면 해당 인덱스의 연락처 필드를 한 줄에 하나씩 출력한다. (총 11줄)

    • 입력이 올바르지 않을 때의 동작을 정의해야한다.

고민의 지점들

1. 클래스의 기본 개념

구조체와 클래스의 차이

C의 구조체를 떠올리며 클래스를 이해해보자. CPP의 구조체는 C와는 다르게 구조체 멤버로 함수를 포함할 수 있다. 클래스와 구조체는 유사한 개념이다. 클래스자료 저장(변수) + 자료 처리(함수) 가 가능한 자료형이고, 특정 용도를 수행하기 위한 변수와 함수를 모아 둔 이라고 말할 수 있다.

CPP의 클래스는 구조체 개념에 딱 한 가지, 접근 제어라는 개념을 추가했다. 그렇다고 클래스를 그저 구조체의 확장 버전이라고 말하기엔 부족하다. CPP은 기본적으로 객체지향 언어이기 때문이다. 객체지향의 관점에서 클래스를 이해해야 한다.

2. 객체지향 프로그래밍의 이해

CPP에서 말하는 Object의 의미는 우리 주변에 존재하는 사물, 또는 대상이다. 따라서 객체지향 프로그래밍은 현실에 존재하는 사물과 대상, 그리고 그들의 행동을 있는 그대로 실체화 시키는 형태의 프로그래밍이다. 그리고 이때 클래스는 객체를 만들어 내기 위한 틀로서 사용되는 개념이다.

2.1. 객체를 이루는 것은 데이터와 기능

42 CPP Module00의 My Awesome Phonebook 과제를 예로 들어보자. 이 과제에서는 연락처를 추가하고 조회하는 프로그램을 만들어야 한다. 이 프로그램에는 전화번호부 객체가 존재할 것이다. 그리고, 아래와 같은 일들을 할 것이다.

  1. 전화번호부에 연락처를 추가하거나 조회한다.

  2. 전화번호부에는 총 8명의 사람이 저장되어 있다.

  3. 전화번호부는 각 사람의 이름, 전화번호를 저장하고 있다.

1은 전화번호부의 행동(behavior)을 의미한다. 그리고 2와 3은 전화번호부의 상태(state)를 의미한다.

이처럼 객체는 하나 이상의 상태 데이터와 하나 이상의 행동(기능)으로 구성된다. 상태 정보는 변수(속성)를 통해, 행동은 함수(메서드)를 통해 구현한다.

class Contact {
private:
    std::string first_name;
    std::string last_name;
    std::string phone_number;


public:
    void set_first_name(const std::string str);
    void set_last_name(const std::string str);
    void set_phone_number(const std::string str);

    std::string get_first_name(void) const;
    std::string get_last_name(void) const;
    std::string get_phone_number(void) const;
};

위와 같은 모습의 클래스를 선언했다.

2.2. 객체와 인스턴스

객체와 인스턴스라는 말이 혼용되어 사용되는 느낌이 들어 헷갈렸다. 정확한 개념을 찾아보니 객체(Object)는 소프트웨어 세계에 구현할 대상이고, 이를 구현하기 위한 설계도가 클래스(Class)이며, 이 설계도에 따라 소프트웨어 세계에 구현된 실체가 인스턴스(Instance)이다. 출처

즉, 메모리에 올라간 특정 클래스 타입의 객체를 인스턴스(instance) 라고 부른다. 하지만 개념적으로 인스턴스는 객체에 포함되므로 두 단어가 같은 뜻인 것처럼 흔히 사용되는 것 같다. 정확히 구분해서 사용하면 좋을 듯.

인스턴스의 특징

  • 인스턴스는 독립된 메모리 공간에 저장된 자신만의 멤버 변수를 가진다.

  • 하지만 멤버 함수는 모든 인스턴스가 공유한다.

2.3. 객체지향 프로그래밍의 특징

  1. 추상화(abstraction)

  2. 캡슐화(encapsulation)

  3. 정보 은닉(data hiding)

  4. 상속성(inheritance)

  5. 다형성(polymorphism)

3. 접근 제어

CPP에서는 객체 지향 프로그래밍의 기본 규칙인 정보 은닉 을 위해 접근 제어(access control)라는 기능을 제공한다. 아래 3개의 접근 제어 지시자를 통해, 클래스 내부의 멤버에 대한 외부에서의 직접적인 접근을 허용하거나 차단할 수 있다.

  • public : 멤버에 관한 모든 외부 접근이 허용

  • private : 모든 접근 차단

  • protected : 상속 관계에 있는 파생 클래스에서의 접근만 허용

참고로 클래스의 기본 접근 제어 권한은 private이며, 구조체 및 공용체는 public 이다.

3.1. 접근 제어가 왜 필요할까? 정보은닉과 캡슐화

#include <iostream>

using namespace std;

struct TV {
  bool powerOn;
  int channel;
  int volume;

  void setVolume(int vol) {
    if (vol >= 0 && vol <= 100) 
      volume = vol;
  }
};

int main() {
  TV lg;
  lg.powerOn = true;
  lg.channel = 11;
  lg.setVolume(50);
  lg.volume = 400; // 의도치 않은 접근 가능
}

TV 구조체의 각 멤버를 정의하고 메인 함수에서 초기화를 해주고 있다. 만약 개발자가 볼륨의 크기를 0부터 100으로 제한하고 싶다면, 위 예제처럼 구조체 내부에 setVolume이라는 함수를 만들어 사용하도록 할 수 있을 것이다.

하지만 위 코드의 문제점은, 여전히 volume 멤버에 대한 접근이 가능하다는 점이다. 그래서 나온 CPP의 해결책이 접근 지시자이다. 아래 코드 처럼 접근 지시자를 이용해 정보 은닉을 실현하고, 보다 안정적인 프로그램을 개발할 수 있다.

#include <iostream>

using namespace std;

struct TV {
private: //외부 접근 차단
  bool powerOn;
  int channel;
  int volume;

public: //외부 접근 허용
  void on() {
    powerOn = true;
    cout << "TV on." << endl;
  }

  void off() {
    powerOn = false;
    cout << "TV off." << endl;
  }

  void setChannel(int num) {
    if (num >= 1 && num <= 999)
      channel = num;
       cout << "채널을 " << num << "(으)로 변경했습니다." << endl;
  }

  void setVolume(int vol) {
    if (vol >= 0 && vol <= 100) 
      volume = vol;
    cout << "볼륨을 " << vol << "(으)로 변경했습니다." << endl;
  }
};

int main() {
  TV lg;
  lg.powerOn = on();
  lg.setChannel(11);
  lg.setVolume(50);
}

멤버변수를 private로 선언하고, 해당 변수에 접근하는 함수를 별도로 정의해서, 안전한 형태로 멤버변수의 접근을 유도하는 것이 바로 정보은닉이며, 이는 좋은 클래스가 되기 위한 기본조건이 된다. 이렇게 어떤 멤버는 보호를 하고, 외부 사용자들에게 접근할 수 있는 인터페이스를 따로 만들어 주는 것을 '캡슐화'라고 부른다.

4. 멤버함수 정의 방법

멤버함수를 정의하는 방법은 두 가지가 있다.

  • 내부 클래스 정의

    • 클래스 내부에서 함수 정의

  • 외부 클래스 정의

    • :: 연산자 (scope resolution)를 사용

예제 코드

#include <iostream>

using namespace std;

class TV {
private:
  bool powerOn;
  int volume;

public: 
  // 내부 클래스 정의
  void on() {
    powerOn = true;
    cout << "TV on." << endl;
  }

  void off() {
    powerOn = false;
    cout << "TV off." << endl;
  }

  // 외부 클래스 정의
  void setVolume(int vol);
};

// 외부 클래스 정의
void TV::setVolume(int vol) {
  if (vol >= 0 && vol <= 100) 
    volume = vol;
  cout << "볼륨을 " << vol << "(으)로 변경했습니다." << endl;
}

int main() {
  TV lg;

  lg.powerOn = on();
  lg.setVolume(50);
}

5. 생성자와 소멸자

인스턴스는 모든 멤버 변수를 초기화 하기 전까지는 사용할 수 없다. private 멤버는 일반적인 방식으로 초기화할 수 없기 때문에, CPP에서는 생성자(constructor) 라는 멤버 함수를 제공한다. 클래스에서 객체를 생성할 때마다 해당 클래스의 생성자가 컴파일러에 의해 자동으로 호출된다.

  • 생성자는 객체의 생성과 동시에 멤버 변수를 초기화해주는 pubilc 함수이다.

  • 클래스 생성자의 이름은 해당 클래스의 이름과 같아야 한다.

  • 생성자는 초기화를 위한 데이터를 인수로 전달받을 수 있다.

  • 클래스 생성자의 원형클래스 선언의 public 영역에 포함되어야 한다.

  • 반환값이 없지만 void형으로 선언하지 않는다.

  • 한 클래스는 여러 개의 생성자를 가질 수 있다.

예제 코드

#include <iostream>

using namespace std;

class TV {
private:
  bool powerOn;
  int volume;

public: 
    TV(void); // 생성자 함수의 원형
}

// 생성자 함수
TV::TV(void){
  powerOn = true;
  volume = 50;
}

int main() {
  TV lg; // 생성자의 암시적 호출
  // TV lg = TV // 생성자의 명시적 호출
}

5.1. 멤버 이니셜라이저 (member Initializer)

멤버 이니셜라이저는 객체 혹은 멤버변수의 초기화를 보다 간단하게 만들어준다. 아래 예제가 이니셜라이저를 사용한 예제이다. :(콜론) 기호 뒤의 volume(50) 이 volume을 50으로 초기화하라는 뜻이다.

class TV {
private:
  bool powerOn;
  int volume;

public: 
    TV(void) : volume(50) {} //이니셜라이저를 통한 초기화
}

따라서, 객체를 생성할 때는 생성자 함수 몸체에서 초기화하는 것과 이니셜라이저를 이용하는 것 두 가지 방법이 존재한다. 일반적으로 멤버변수 초기화를 할때는 이니셜라이저를 선호한다고 한다. 선언과 동시에 초기화가 이루어지니 대입연산보다 성능 상의 이점이 있는 것 같다.

  • 초기화할 멤버가 여러개면 ,(콤마) 기호로 나열하면 된다.

  • const 멤버변수도 이니셜라이저를 이용하면 초기화가 가능하다.

5.2. 소멸자

생성자와 반대로 객체 소멸시 반드시 호출되는 것이 소멸자이다.

  • 클래스의 이름앞에 ~ 기호가 붙는 형태의 이름을 갖는다.

  • 반환형이 선언되어 있지 않고, 실제로 반환되지 않는다.

  • 소멸자는 객체 소멸과정에서 자동으로 호출된다. 또한 직접 소멸자를 정의하지 않으면, 디폴트 생성자와 마찬가지로 자동 삽입된다.

  • 만약 생성자 내에 new 연산자를 이용해 할당해 놓은 메모리가 있다면 delete 연산자를 이용해 소멸자에서 소멸해야 한다.

6. 클래스 외부에서의 멤버변수 접근

class Contact {
private:
    std::string first_name;
    std::string last_name;
    std::string phone_number;

public:
    void set_first_name(const std::string str);
    void set_last_name(const std::string str);
    void set_phone_number(const std::string str);

    std::string get_first_name(void) const;
    std::string get_last_name(void) const;
    std::string get_phone_number(void) const;
};

6.1. 엑세스 함수

My Awesome Phonebook 과제 예제를 다시 가져와 보면, 멤버함수들에는 set, get과 같은 이름이 붙어있는 걸 알 수 있다. CPP에서는 이런 함수명을 자주 쓰는데, 흔히 엑세스 함수 라고 부른다. 이들은 멤버변수를 private로 선언하면서 클래스 외부에서의 멤버변수 접근을 목적으로 정의되는 함수들이다.

6.2. const 함수

그리고 getter함수들 뒤에 const 지시자가 붙어 있는 걸 알 수 있다. const이 함수 내에서는 멤버변수에 저장된 값을 변경하지 않겠다 라는 선언이다. 따라서 const 선언이 추가된 멤버함수 내에서 멤버변수의 값을 변경한다면 컴파일 에러가 발생한다. const 함수 내에서는 const가 아닌 함수의 호출이 제한된다. 아예 멤버변수의 값이 변경될 수 있을 일말의 가능성조차 허용하지 않기 위해서다.

사실 처음에는 CPP의 클래스와 객체지향 프로그래밍 개념을 학습하면서 C에 비해 개발의 자유도가 제한되고 너무 복잡한 기능들이 추가된 것 아닌가 생각했었다. 그런데 바로 그게 이 모든 새 기능들이 추가된 이유인 것 같다. 제한된 방법으로의 접근만 허용을 해서 잘못된 값이 저장되지 않도록 도와주는 것이고, 또 실수를 했을 때 실수가 쉽게 발견되도록 해야 한다는 일관된 원칙이 느껴져서 설계 자체에 재미를 갖게 됐다.

Last updated