[C++] struct에서 class로

C언어의 기본 문법이나 OOP(객체지향)에 대한 기본 개념을 알고 있다는 전제로 작성된 글이다. 

예제 코드의 간결함을 위해 아래 내용은 생략하고 기록하였다. 

#include <iostream>
using namespace std;

C → C++

// C++
struct Sample {
    int id;
    char name[10];
};

/* C
typedef struct {
    int id;
    char name[10];
} Sample;
*/

int main(void) {
    Sample sample = { 1, "DeneV" };
    cout << sample.id << ": " << sample.name << endl;
    return 0;
}

C에서 typedef를 이용해 구조체를 생성해야 호출 시에 구조체명으로만 호출할 수 있었지만, C++에서는 typedef 없이 struct를 정의해도 된다.

만약 OOP의 개념을 살려 struct를 객체로 사용한다면 아래와 같은 코드가 만들어질 수 있다. 

struct Staff {
    int id;
    char name[10];
	
    void PrintInfo(void);
};

void Staff::PrintInfo(void) {
    cout << id << ": " << name << endl;
}

int main(void) {
    Staff me = { 1, "DeneV" };
    me.PrintInfo();
    return 0;
}

C++는 C와 달리 OOP를 사용할 수 있고, struct와 더불어 class를 제공한다. 


Class

class 키워드를 통해 선언하며 접근 제어자를 설정할 수 있다. 

  • public: 항상 접근 허용
  • protected: 외부에서 접근할 수 없지만, 상속 관계의 객체에서 접근 가능
  • private: 객체 내에서만 접근 허용
#include <iostream>
#include <cstring>

using namespace std;

class Staff {
    private:
        int id_;
        char name_[10];
    public:
        void InitInfo(int, char*);
        void PrintInfo(void);
};

void Staff::InitInfo(int new_id, char* new_name) {
    id_ = new_id;
    strcpy(name_, new_name);
}

void Staff::PrintInfo(void) {
    cout << id_ << ": " << name_ << endl;
}

int main(void) {
    Staff me;
    me.InitInfo(1, "DeneV");
    me.PrintInfo();
    return 0;
}

id와 name은 외부에서 접근할 수 없는 private으로 선언되었고, 그 외 메서드들은 함수 외부에서도 접근이 가능한 public이다. 따라서 main 함수에서 호출된 것을 볼 수 있다. 

위에서 봤던 struct에 선언된 멤버들은 모두 public으로 선언되었다고 볼 수 있다. 그리고 class에 별도로 접근제어자를 명시하지 않으면 private으로 처리된다. 이를 통해 데이터가 외부에서 수정되거나 훼손되는 것을 방지할 수 있다. 


파일 분할

하나의 main 파일에 여러 객체의 선언, 정의, 호출 코드를 작성하게 된다면 코드가 난잡해질 수 있다. 따라서 파일을 크게 3 부분으로 분리해 관리하는 방식을 선호한다. 

  • staff.h: 선언
  • staff.cpp: 정의
  • main.cpp: 호출

파일의 구성을 크게 보면 선언, 정의, 호출의 과정으로 나누어 볼 수 있다. 

staff.h

#ifndef STAFF_H_
#define STAFF_H_

// 선언
class Staff {
    private:
        int id_;
        char name_[10];
	
    public:
        void InitInfo(int, char*);
        void PrintInfo(void);
};

#endif

staff.cpp

#include <iostream>
#include <cstring>

#include "staff.h"  // 선언부 포함

using namespace std;

// 정의
void Staff::InitInfo(int new_id, char* new_name) {
    id_ = new_id;
    strcpy(name_, new_name);
}

void Staff::PrintInfo(void) {
    cout << id_ << ": " << name_ << endl;
}

main.cpp

#include "staff.h"

int main(void) {
    // 호출
    Staff me;
    me.InitInfo(3, "DeneV");
    me.PrintInfo();
    return 0;
}

마지막 main 파일의 코드를 보면 내부 구현을 확인할 필요 없이 깔끔하게 객체를 호출해 사용하는 것을 볼 수 있다. 


생성자

생성자(constructor)는 객체가 생성됨과 동시에 멤버 변수를 초기화할 수 있도록 도와주는 메서드이다. Python의 __init__의 역할이다. 

생성자는 class의 이름과 동일한 이름의 메서드로 선언된다. 그리고 반환 자료형을 명시하지 않는 특수한 형태이다.

class Sample {
    private:
        int id_;
        int content_;
    public:
        Sample(int id, int content) {  // 생성자
            id_ = id;
            content_ = content;
        }
        void Print(void) {
            cout << id_ << "__" << content_ << endl;
        }
};

int main(void) {
    Sample sample(1, 35718);  // 초기화
    sample.Print();
    return 0;
}

생성자를 통해 초기화할 때는 함수를 호출하듯이 파라미터를 넘겨주면 된다.

그런데 만약 class의 생성자에서 다른 class의 생성자를 호출해야 하는 상황이 있을 수 있다. 아래 예시를 살펴보자.

class Person {
    private:
        int id_;
    public:
        Person(int id) {
            id_ = id;
        }
};

class Staff {
    private:
        Person staff_;
        Person team_leader_;
    public:
        Staff(int id) {
            // Person 초기화 후 staff에 전달
        }
};

Person을 초기화하고 Staff에 전달해줄 수도 있지만 번거롭다. 따라서 Staff에 id를 전달하고 자체적으로 Person을 생성한 뒤 스스로를 초기화하도록 하고 싶다. 

class Staff {
    private:
        Person me_;
        Person team_leader_;
    public:
        Staff(int my_id, int leader_id)
            :me_(my_id), team_leader_(leader_id) {}
};

int main(void) {
    Staff staff(37, 3);
    return 0;
}

:멤버의 형태로 작성하면 멤버 class의 생성자를 호출한다. 이렇게하면 별도의 처리 없이 멤버 객체를 초기화할 수 있다. 이를 initializer라고 한다. 

만약 생성자가 선언되지 않으면 기본 생성자가 호출되고 멤버는 초기화되지 않는다. 

Sample sample;  // 기본 생성자
Sample sample(...)  // 생성자가 정의된 경우

소멸자

생성자와 반대로 객체가 소멸될 때 호출되는 메서드이다. ~객체명의 형태로 선언된다. 

class Sample {
    public:
        ~Sample() {
            // do something
        }
}

만약 객체 안에서 메모리를 할당(new)했다면, 소멸자를 통해 해제(delete)하는 등 역할로 사용할 수 있다.  


객체 간의 정보

객체에 다른 객체를 전달하는 예시이다. 참조자(&)를 활용해 객체에 접근할 수 있도록 해준다. 

class Sender {
    public:
        int num_;
};

class Receiver {
    public:
        int num_;
        void GetNum(Sender &sender) {
            num_ = sender.num_;
            cout << "Num from Sender: " << num_ << endl;
        }
};

int main(void) {
    Sender sender;
    Receiver receiver;
	
    sender.num_ = 10;
    receiver.GetNum(sender);  // Num from Sender: 10
    
    return 0;
}

const를 이용한 보호

객체 멤버를 보호하기 위해 멤버를 private으로 설정하고, 특별하게 정의된 getter와 setter를 이용해 접근하는 것이 일반적이다. 이때 const를 이용한 함수는 객체의 멤버가 수정되지 않도록 하는 역할을 한다. 만약 멤버 변수가 수정되면 컴파일 에러가 발생한다. 

class Data {
    private:
        int data_;
    public:
        bool SetData(int n) {
            if (0 < n) {
                data_ = n;
                return false;
            }
            return true;
        }
        int GetData(void) const {
            data_ -= 1;  
            // error: assignment of member ‘Data::data_’ in read-only object
            return data_;
        }
};

int main(void) {
    Data data;
    bool err = data.SetData(9);
    if (!err) {
        int num = data.GetData();
        cout << "Data: " << num << endl;
    }
    return 0;
}

GetData 메서드는 const로 정의되어 있다. 따라서 GetData 내부에서 멤버 변수를 수정하려고 할 경우, 컴파일 에러가 발생한다. 만약 외부에서 객체를 const로 선언했다면 getter도 반드시 const여야 컴파일 에러가 발생하지 않는다. 


this

this객체 자신을 의미하는 포인터이다. 

class Person {
    private:
        int age_;
    public:
        Person(int age) {
            this->age_ = age;
        }
        Person* GetSelf(void) {
            return this;
        }
        Person& CopySelf(void) {
            return *this;
        }
};

int main(void) {
    Person me(20);
    Person * ptr_me = me.GetSelf();  // pointer
    Person &ref_me = me.CopySelf();  // Person
    return 0;
}

멤버변수임을 명시적으로 나타내거나 객체 자신을 외부로 반환할 때 등 다양한 상황에서 활용될 수 있다. 


friend

일반적으로 객체에서 private 접근자를 선언하면 객체 내에서만 접근 가능하다. 하지만 friend 관계를 가진 객체도 접근이 가능하다. A → B로 접근하기 위해서는 A에서 B를 friend로 지정해 주어야 한다. 객체 내 friend의 선언 위치는 관계없다. 

class Person {
    private:
        int age_;
        friend class MyFriend;
    public:
        Person(int age) :age_(age){}
};

class MyFriend {
    public:
        void PrintFriendAge(Person &fnd) {
            cout << "Age: " << fnd.age_ << endl;
        }
};

int main(void) {
    Person me(20);
    MyFriend my_friend;
    my_friend.PrintFriendAge(me);
    return 0;
}

Person에서 friend를 선언하는 시점에 아직 MyFriend 객체가 정의되지 않을 것을 볼 수 있다. 하지만 위 예시는 컴파일 에러가 발생하지 않고 정상적으로 값을 출력한다. 


static

C언어에서 static는 메모리의 데이터 영역에 값이 할당된다. 따라서 함수의 실행이 끝나면 소멸되는 지역변수와 달리 프로그램이 종료될 때까지 그 값이 유지된다. 

void PrintCounter(void) {
    static int counter;
    counter++;
    cout << counter << endl;
}

int main(void) {
    PrintCounter();
    PrintCounter();
    PrintCounter();
    return 0;
}
1
2
3

그리고 값을 초기화하지 않으면 0으로 초기화된다. 보다시피 PrintCounter 내의 counter가 0으로 초기화된 후, 값을 저장하고 있는 것을 볼 수 있다.

 

class Object {
    private:
        static int cnt;
    public:
        Object(void) {
            cnt++;
        }
        void PrintCounter(void) {
            cout << cnt << " Objects inited." << endl;
        }
};
int Object::cnt = 0;

int main(void) {
    Object obj1;
    Object obj2;
    Object obj3;
	
    obj1.PrintCounter();  // 3 Objects inited.
    return 0;
}

이번에는 class 내에 static을 선언했다. 이렇게하면 객체들은 cnt라는 static 변수를 공유하고, static 변수에 접근할 수 있는 권한이 생긴다. 

 

class Object {
    private:
        static int n1_;
        int n2_;
    public:
        Object(int n):n2_(n) {}
        static void PrintMembers(void) {
            cout << n1_ << n2_ << endl;
            // invalid use of member ‘Object::n2_’ in static member function
        }
};
int Object::n1_ = 0;

이번에는 static 함수를 선언했다. 여기서 중요한 점은 static 함수는 객체의 멤버가 아니다. 따라서 위 예시를 보면 static 함수에서 private 멤버에 접근하려고 했을 때 컴파일 에러가 발생했다.