본문 바로가기
Basic/C/C++

[C++ 언어] 제 4 강 : 클래스2

by boxbop 2012. 2. 11.
반응형


 이번 장에서는 본격적으로 '클래스'에 대해서 설명하겠습니다. 저번 장에서는 C언어의 구조체 관점(?)에서 살펴보았죠. 클래스의 아주 중요한 정보은닉, 캡슐화에 대한 개념을 설명하고 생성자, 소멸자 그리고 기타 필요한 설명을 이어나가도록 하겠습니다~

 정보 은닉(Information Hiding)

정보 은닉은 객체 내부의 존재하는 데이터를 숨긴다고 생각하시면 됩니다. 결론부터 말씀드리자면 객체의 외부에서 객체 내에 존재하는 맴버 변수에 직접 접근하는 권한을 허용하면 안됩니다. 즉, 객체 내에 존재하는 맴버 변수에 직접 접근하는 것은 정보 은닉에 위배됩니다.

 class point
{
   public:
          int x;
          int y;
 };

 int main(void)
{
  point p;
  p.x = 10;
  p.y = 10;
  return 0;
 }

 point 라는 클래스는 x좌표와 y좌표값을 맴버 변수로 정의하고 있습니다. 메인 함수내에서 p라는 point형 객체를 만들어준 뒤에 p.x = 10; p.y = 10; 라는 코드를 통해 클래스 맴버 변수의 값을 바꾸어 줍니다. 즉, 이처럼 외부에서 맴버 변수로의 직접 접근이 가능해집니다. 
 
 그렇다면 어떻게 정보 은닉을 할 수 있을까요? 모든 맴버 변수를 private로 선언해주어야 합니다. 그러나 private 선언 후에는 직접 접근을 할 수 없으므로 간접 접근을 위한 특별한 '경로'를 만들어 주어야 합니다. 맴버 함수로 말이죠~

 class point
{
   private:
          int x;
          int y;
   public:
        void change_point(int _x, int _y)
        {
             x = _x;
             y = _y;
         }
 };

 int main(void)
{
   point p;
   p.change_point(10,10);
   return 0;
 }

 클래스의 선언에서 private나 public 키워드를 사용하지 않고 그냥 선언해준다면 기본적으로 private로 선언이 됩니다. change_point함수는 public 맴버이므로 외부에서 접근이 가능하고, private로 선언된 맴버 변수에도 접근이 가능합니다. 즉, 클래스 외부에서 이 함수를 이용해서 맴버 변수 x, y에 간접적인 접근이 가능해지죠. 보통 이런 함수를 엑세스 함수(메소드)라고 부릅니다.

 이처럼 정보은닉은 어려운 개념은 아닙니다~ 직관적으로 쉽게 이해할 수 있는 부분이죠~ 또 다른 개념인 캡슐화에 대해서 알아보겠습니다. 

 캡슐화

캡슐화란? 관련 있는 데이터와 함수를 하나의 단위로 묶는 것 입니다. 즉, 관련 있는 데이터와 함수를 클래스라는 하나의 캡슐 내에 모두 정의하는 것 입니다. 

 class point
{
   private:
          int x;
          int y;
   public:
        void change_point(int _x, int _y)
        {
             x = _x;
             y = _y;
         }
        int getx() {return x;}
        int gety() {return y;}
 };

 class pointview
{
  public:
       void showdata(point p)
       {
           cout << "x 값 : " << p.getx() <<endl;
           cout << "y 값 : " << p.gety() <<endl;
        }
  };

 int main()
{
    point p;
    p.change_point(10, 10);
   
    pointview view;
    view.showdata(p);
    return 0;
 }

 감이 오시나요~? 사실상 pointview 클래스는 상당히 불필요한 존재입니다. point클래스에 좌표 값을 출력할 함수가 없으므로 새로운 클래스를 정의하여 출력시키고 있죠. 그것도 view.showdata(p)라는 call-by-value를 통해서 아주 무겁게(?) 출력하고 있습니다. 무겁게라는 말이 이해 안가시면 레퍼런스 쪽을 다시 공부하고 오세요~!!! 무튼 캡슐화란 관련 있는 데이터와 함수를 하나의 클래스로 정의하는 것이라고 하였습니다. 그런데 위의 예제에서 보면 x, y좌표에 관련된 데이터와 함수가 두 개의 클래스로 양분되어 있습니다. 이는 캡슐화의 원칙에 어긋나버리게 되는거죠~ 그러므로 캡슐화에 맞도록 수정해보겠습니다~

 class point
{
   private:
          int x;
          int y;
   public:
        void change_point(int _x, int _y)
        {
             x = _x;
             y = _y;
         }
        int getx() {return x;}
        int gety() {return y;}
   
        void showdata();
 };

void point::showdata()
{
           cout << "x 값 : " << x <<endl;
           cout << "y 값 : " << y <<endl;
}
     

 int main()
{
    point p;
    p.change_point(10, 10);
  
    p.showdata();
    
     return 0;
 }

 캡슐화는 생각보다 상당히 중요한 개념입니다. 실제로도 이 부분을 무시하고 프로그래밍하면 추 후에 문제가 발생했을때 상당한...뻘..짓(?)으로 이어지게 되죠~

 생성자(constructor) & 소멸자(destructor)

바로 예제를 보면서 설명해보겠습니다.

class person
{
  private:
     char name[20];
     char phone[20];
     int age;

  public:
     void show();
 };

void person::show()
{
   cout<<name<<phone<<age<<endl;
 }

int main()
{
   person p = {"kim", "010-1111-2222", 10};
   p.show();
   return 0;
 }

 진한 부분을 보시면 객체를 생성과 동시에 원하는 값으로 초기화 하려고 하고있습니다. 그러나 중요한 점은 맴버 변수가 private로 선언되어 있다는 점이죠. 즉, 위와 같은코드의 접근은 허용되지 않습니다. 그렇다고 엑세스 함수를 사용해서 접근을 하면 말처럼 "생성과 동시에 초기화" 가 아니지 않습니까~ 액세스 함수를 사용하면 "생성 후 초기화" 가 될 뿐이죠.. 이러한 문제를 해결해 주는 것이 생성자입니다.

 class person
{
   char name[20] = "boxbop";
   char phone[20] = "010-1111-2222";
   int age = 10;
 }

 그렇다고 이러한 초기화 방법이 가능할까요?! 분명히 클래스나 구조체에서는 선언하는데 있어서 맴버를 초기화 할 수 없습니다~ 무조건 맴버 변수를 선언!만 할 수 있습니다. 그러나 C#, java에서는 위와 같은 초기화가 가능하긴 합니다만 C나 C++에서는 절대 불가능합니다~ 참고하시구요~

 그렇다면 생성자는 어떤모양일까요~? 그전에 객체의 생성과정을 잠깐 살펴보겠습니다. 객체는 메모리를 할당한 후에 -> 생성자를 호출합니다. 생성자는 무엇이냐면 함수이고, 클래스의 이름과 같은 이름을 가지고 있고, 리턴하지도 않고 리턴 타입도 선언되지 않습니다. 다음 예제를 살펴봅시다~

class person
{
  private:
     char name[20];
     char phone[20];
     int age;

  public:
     void show();
   
     person()
     {
          char name[20] = "boxbop";
          char phone[20] = "010-1111-2222";
          int age = 10;
      }
      
 };

볼드 처리된 부분이 생성자 입니다. 리턴 타입도 없고 리턴도 없습니다. 클래스의 이름과도 같죠~? 딱 보시고 아! 생성자구나! 라고 생각하면 됩니다~ 따라서

int main(void)
{
   person p;
   p.show();
  
   return 0;
 }

 위와 같은 코드의 출력은 boxbop, 010-1111-2222, 10 을 출력하게 됩니다. 생성자 때문에 단지 객체를 생성만 해주었을 뿐인대 초기화가 되어있죠~ 물론 생성자가 없는 상태에서 객체를 생성해주면 쓰레기 값이 들어있습니다. 생성자를 좀 더 바꾸어 봅시다.

     person(char* _name, char* _phone, int _age)
     {
         strcpy(name, _name);
         strcpy(phone, _phone);
         age = _age;        
      }
      

 이렇게 바꾸어 주었습니다. 설마 name = _name; 이렇게 코딩하시는 분들 계시죠~? 그렇게 하면 안되는거 아시겠죠~? 모르신다면 포인트쪽을 다시 한번 공부해오시길 부탁드리겠습니다~ 힌트는 메모리 주소!!! 여기까지~ㅋㅋㅋ

 자 그렇다면 위와 같은 생성자를 어떻게 사용할까요~? 예제를 통해 보여드리겠습니다.

 int main()
{
   person p (boxbop,010-1111-2222,10);
   p.show();
   return 0;
 }

 객체를 생성과 동시에 초기화 한 것이 보이시죠~? 이렇게 생성자는 객체 생성 시 원하는 값으로 초기화하기 위한 용도로 사용됩니다~

 디폴트(Default) 생성자

생성자에는 몇 가지 특징이 있습니다.
1. 생성자를 정의하지 않으면 디폴트 생성자가 자동 삽입됩니다.
2. 생성자도 함수이므로 오버로딩이 가능합니다.
3. 생성자도 함수이므로 디폴트 매개 변수의 설정이 가능합니다.

class point
{
   int x,y;
   public:
   point() {}
 }

볼드 처리 된 부분이 디폴트 생성자의 형태입니다. 그러나 디폴트 생성자같은 경우에는 프로그래머가 정의해 놓은 생성자가 하나라도 존재하면 디폴트 생성자가 자동으로 삽입되지 않습니다.

class point
{
     int x,y;
   public:
     point(int _x, int _y) { x = _x, y = _y; }
 }

int main(void)
{
   point p1(10, 20); // 가능
   point p2; // 불가능!!!

   return 0;
 }

위와 같은 예제는 컴파일 에러를 발생시키죠~ 그러나 생성자도 오버로딩이 된다고 하지 않았습니까~? 다음과 같이 수정해주면 됩니다.

class point
{
     int x,y;
   public:
     point(int _x, int _y) { x = _x, y = _y; }
     point() {}
 }

int main(void)
{
   point p1(10, 20); // 가능
   point p2; // 가능 (디폴트 생성자)

   return 0;
 }

 위 예제는 디폴트 생성자를 따로 삽입했습니다. 때문에 p2의 선언이 가능해진 것 입니다. 그리고 생성자도 함수라고 하지 않았습니까~? 때문에 디폴트 매개변수를 설정할 수 있습니다. 다음과 같이 말이죠.

class point
{
     int x,y;
   public:
     point(int _x=0, int _y=0) { x = _x, y = _y; }
     
 }

int main(void)
{
   point p1(10, 20); // 가능
   point p2; // 가능 (디폴트 매개 변수)

   return 0;
 }

디폴트 매개 변수 때문에 디폴트 생성자가 없어도 p2의 선언이 가능합니다.

 생성자와 동적할당

이번에는  생성자 내에서 메로리 공간을 동적 할당하는 경우에 대해서 살펴보겠습니다. 예제는 조금 복잡하겠네요~

#include <iostream>
using std::cout;
using std::endl;

class person
{
           char *name, *phone;
           int age;
     public:
           person(char* _name, char* _phone, int _age);
           void show();
 };

 person::person(char* _name, char* _phone, int _age)
{
      name = new char[strlen(_name)+1];
      strcpy(name, _name);
      phone = new char[strlen(_phone)+1];
      strcpy(phone, _phoen);
      age = _age;
 }
 void person::show()
{
     cout<<name<<phoen<<age<<endl;
 }

int main()
{
     person p("kim", "010-1111-2222",10);
     p.show();
     return 0;
 }

 p라는 객체를 생성하고 있죠? 제일 먼저 메모리 공간이 할당되고, p라는 이름이 부여됩니다. 그다음으로는 생성자를 호출하면서 선언되어 있던 문자열과 정수가 인자로 전달되죠. 생성자 내에서는 전달된 문자열의 길이를 계산해서 메모리 공간을 할당하고 문자열을 복사합니다.
 결과적으로 객체 p는 main 함수 내에서 생성되었으므로 스택 영역에 할당이 됩니다만 맴버 변수 name과 phone이 가리키는 메모리 공간은 힙영역이 됩니다. 그러나 위 코드에서는 생성자 내에서 동적 할당한 메모리 공간을 해제해 주지 않고 있습니다. 따라서 다음과 같이 해결해주어야 합니다.
    
#include <iostream>
using std::cout;
using std::endl;

class person
{
           char *name, *phone;
           int age;
     public:
           person(char* _name, char* _phone, int _age);
           void show();
           void delmemory();
 };

 person::person(char* _name, char* _phone, int _age)
{
      name = new char[strlen(_name)+1];
      strcpy(name, _name);
      phone = new char[strlen(_phone)+1];
      strcpy(phone, _phoen);
      age = _age;
 }
 void person::show()
{
     cout<<name<<phoen<<age<<endl;
 }
 void person::delmemory()
{
   delete []name;
   delete []phone;
 }

int main()
{
     person p("kim", "010-1111-2222",10);
     p.show();
     p.delmemory();
     return 0;
 }

 이렇게 보면 문제가 해결된 듯 합니다. 그러나 사실은 그렇지 않죠. 만약 이런식의 클래스가 여러개가 있다고 한다면 상당히 골치아파집니다. 귀찮죠.... 그리고 메모리 해제를 놓칠 수 있습니다. 사람은 완벽하지 않기 때문이죠. 그래서 등장한 것이 소멸자(destructor)입니다.

 소멸자(destructor)

 객체의 소멸 과정도 객체의 생성 과정과 마찬가지로 소멸자 호출 -> 메모리 반환이라는 비슷한 과정을 거치게 됩니다. 소멸자의 특징은 다음과 같습니다.

1. 함수입니다.
2. 클래스의 이름 앞에 '~'가 붙습니다.
3. 리턴, 리턴 타입이 존재하지 않습니다.
4. 매개 변수를 받지 않고, 오버로딩, 디폴트 매개 변수의 선언도 불가능합니다.

 바로 다음과 같은 예제를 살펴보겠습니다.

#include <iostream>
using std::cout;
using std::endl;

class person
{
           char *name, *phone;
           int age;
     public:
           person(char* _name, char* _phone, int _age);
           void show();
           ~person();
 };

 person::person(char* _name, char* _phone, int _age)
{
      name = new char[strlen(_name)+1];
      strcpy(name, _name);
      phone = new char[strlen(_phone)+1];
      strcpy(phone, _phoen);
      age = _age;
 }
 person::~person()
{
      delete []name;
      delete []phone;
 }
 void person::show()
{
     cout<<name<<phoen<<age<<endl;
 }


int main()
{
     person p("kim", "010-1111-2222",10);
     p.show();
     return 0; /*리턴과 동시에 p객체 소멸
                   이 부분에서 소멸자 호출*/
 }

 볼드 처리된 부분을 보시면 리턴타입도, 인자도 받지 않습니다. 그리고 소멸자는 객체 소멸시 자동적으로 호출됩니다. 즉 소멸자의 가장 중요한 특징은 객체 소멸 시 반드시 한번 호출된다는 것입니다.

 정리하자면 생성자 내에서 메모리를 동적 할당하는 경우, 이를 해제하기 위해서 반드시 소멸자를 정의해야 합니다!

 디폴트(default) 소멸자

디폴트 생성자를 살펴보았으니 어렵지 않게 이해할 수 있습니다. 디폴트 생성자 처럼 아무것도 정의해주지 않으면 자동으로 삽입이 됩니다.

class point                                     class point
{                                                   {
   int x, y;                                          int x, y;
 public:                                           public: 
   void show();                                   point() {}
 }                                                    ~point() {}
                                                       void show()
                                                      }

 왼쪽과 오른쪽의 코드는 완전하게 동일합니다. 대충 이해가 가셨죠~?

 클래스 그리고 배열

C++에서는 이러한 객체 포인터 배열도 선언이 가능합니다. C언어에서 구조체 배열기억나시죠~? 동일한 개념입니다. 다만 객체 별이 생성되기 위해서는 void생성자의 호출이 요구됩니다. 일단 보시죠

class point
{
         int x, y;
    public:
         point()
         {
             cout<<"void생성자 호출"<<endl;
             x = y = 0;
          }
         point(int _x, int _y)
         {
             x = _x, y = _y;
          }
         void change(int _x, int_y) { x = _x, y = _y; }
 };

 int main()
 {
      point array[3]; //void 생성자의 호출이 요구됨

      array[0].change(1,1);
      array[1].change(2,2);
      array[2].change(3,3):

      return 0;
  }

 출력결과를 보시면 "void생성자 호출" 이라는 메시지가 총 3번 출력됩니다. 즉, 객체 배열이 생성되기 위해서는 void 생성자의 호출이 요구된다는 이야기죠. 일단 아시다시피 배열 안에 객체가 존재합니다. 바로 이어서 객체 포인터 배열로 넘어가겠습니다. 객체 포인터 배열이란 객체를 가리킬 수 있는 포인터로 구성이 되어 있는 배열을 의미합니다. 추가적으로 다음 예제를 통해서 어떻게 객체를 동적으로 생성 및 소멸하는지도 알아보겠습니다.

class point
{
         int x, y;
    public:
         point()
         {
             cout<<"void생성자 호출"<<endl;
             x = y = 0;
          }
         point(int _x, int _y)
         {
             x = _x, y = _y;
          }
         void change(int _x, int_y) { x = _x, y = _y; }
 };

 int main()
 {
      point *array[3];
  
      array[0] = new point(1,1);
      array[1] = new point(2,2);
      array[2] = new point(3,3);

      array[1]->change(4,4); //포인터에 접근은 ' -> ' 연산자 사용

      delete array[0];
      delete array[1];
      delete array[2];

      return 0;
  }

 메인함수 첫 번째 줄에서는 포인터 배열을 선언하고 있습니다. 따라서 point객체 3개를 가리킬 수 있는 배열이 생성됩니다. 즉 point객체의 주소값을 저장할 수 있는 배열이 생성되죠. new point(1,1)은 1, 1을 인자로 받을 수 있는 point 클래스의 생성자를 호출하면서 point객체를 생성합니다. 물론 힘영역에 생성이 될 것이고, 생성된 객체의 주소 값이 point* 형으로 반환될 것입니다. 단, 여기서는 객체의 배열이 생성되는 것이 아니므로 void생성자의 호출이 요구되지는 않습니다. 동적할당 과정에서 다르게 정의되어 있는 생성자를 호출하기 때문이기도 하구요~ 이해가 충분히 가셨을꺼라 믿습니다~

 this 포인터

맴버 함수 내에서는 this라는 이름의 포인터를 사용할 수 있습니다. this 포인터는 포인터의 개념을 잘 잡고 있다면 예제만으로도 아주 쉽게 이해할 수 있을겁니다.

class point
{
   public:
      person*  GetThis()
      {
          return this; //this 포인터를 리턴
       }
  };

int main()
{
    point *ptr = new person();
    cout<<"포인터 ptr의 값 :"<< ptr <<endl;
    cout<<"ptr의 this 값 :" << ptr->GetThis()<<endl;
    return 0;
 }

출력값을 살펴보시면 알겠지만 포인터 ptr의 값과 ptr의 this값이 동일합니다. this는 자기 자신을 가리키는 용도로 사용되는 포인터 입니다. 자기 참조 포인터라고 하죠. this 포인터의 유용함을 한번 더 살펴보겠습니다.

 class data
{
    int aaa;
   public:
    data(int aaa)
    {
        aaa = aaa;
     }
  };

위와 같은 클래스가 있다고 봅시다. 볼드처리된 부분을 보시면 의아해 하실겁니다. 왼쪽의 aaa와 오른쪽의 aaa가 서로 어떤 aaa인지 구분이 안가죠. 맴버 변수인 aaa인지 매개변수로 받는 aaa인지 말입니다. 우리의 의도는 맴버변수 aaa에 매개변수 aaa를 대입하는 겁니다. 때문에 왼쪽이 맴버변수, 오른쪽이 매개변수가 되어야 우리가 의도한 코드가되는거죠. this포인터를 이용하여 다음과 같이 변경해줍니다.
 class data
{
    int aaa;
   public:
    data(int aaa)
    {
        this->aaa = aaa;
     }
  };

만약 이러한 클래스를 메인함수에서 data a(111);으로 생성해주었다고 해봅시다. 이 객체는 주소값 0x11번지에 할당되어 있습니다. 이 상황에서 클래스 내부에 있는 this는 0x11번지를 가리키는 포인터가 되는겁니다. 때문에 this->aaa는 0x11번지에 할당된 객체의 aaa라고 인식을 하게됩니다. 신기하지않나요~? 객체의 주소 값을 가지고 지역 변수(매개변수)에 접근을 하다니 말입니다!!! 그러나 이러한 문제는 변수의 이름을 바꾸어주는 것으로 상당히 간단하게 해결할 수 있겠죠~?

 friend 선언

private으로 선언된 맴버 변수는 외부 접근이 허용되지 않는다고 설명했습니다. 그러나 frined 선언을 통해서 private으로 선언된 맴버 변수의 접근을 허용할 수 있습니다.

class number
{
         int val;
      public:
         number()
         {
              val = 0;
          }
          friend void setting (number& c, int val);
 };

 void setting(number& c, int val) // 이건 전역함수입니다!!!
{
     c.val = val;
 }

 분명히 클래스 외부로 나온 setting함수는 전역 함수 입니다. 그럼에도 불구하고 c.val 이라는 코드로 number 객체의 private 맴버인 val에 접근을 하고 있지요. 이것이 가능한 이유는 클래스 내에서 setting 함수를 friend 키워드를 통해 정의해주었기 때문입니다. 즉, 전역함수 void setting(number& c, int val)을 friend로 선언하고 있습니다.
 다시 말하자면 클래내에서 함수를 정의할때 friend 키워드를 붙여주게 되면 이는 전역함수라고 취급을 해버리면서 선언되는 전역함수는 해당 클래스의 private 맴버 변수에도 접근을 허용하게 해줍니다.

 클래스에 대한 friend 선언

friend 선언은 클래스에도 사용이 가능합니다. 그러나 여기서는 방향성에 대해서 주의하셔야 합니다.

class A
{
   private:
        int number;
        friend class B; //B에게 내 모든걸 다줄꺼야!
 };

 class B
{
   private:
        void setting(A& a, int value)
        {
              a.number = value;
         }
 };

 클래스 A에서 보시면 B 클래스를 friend로 선언하고 있습니다. 이것은 A클래스는 B클래스에게 private 영역의 접근을 허용하겠다는 의미가 됩니다. "B 클래스는 나의 private 맴버 변수에 접근을 해도 좋다!!" 라고 알려줍니다. 그러나 B클래스는 A클래스를 friend로 선언하지 않았기 때문에 A클래스는 B클래스의 private 맴버 변수에 접근할 수 없게 됩니다. 이것이 friend 선언의 단방향성 입니다.

 이번 장은 여기서 마치도록 하겠습니다.... 생각보다 많이 길어졌네요 ㅠㅠㅠ 사실 어제 포스팅하다가 졸려서 백업해놓고 오늘 마무리했습니다. 그래도 나름 오래 걸렸어요...ㅠㅠㅠ 오늘은 토요일인대... 불토인대...ㅠㅠㅠㅠㅠ흑흑 열심히 해야죠!
반응형