C++ 정리 - Class, Inheritance
Class
OOP(Object Oriented Programming)의 특성
- 추상화(Abstraction) : 공통의 기능이나 속성을 묶어 클래스로 정의한다.
- 캡슐화(Encapsulation) : 객체의 세부 구현을 내부로 감추고 최소한의 인터페이스만 객체 외부로 노출한다.
- 상속(Inheritance) : 자식 클래스가 부모 클래스의 멤버를 물려받아 확장이 가능하도록 구현한다.
- 다형성(Polymorphism) : 같은 요청을 받아도 객체에 타입에 다르게 동작하도록 구현한다.
클래스(Class)와 구조체(Struct)의 차이
- C++에서
class
와struct
지정자는 거의 동일하다. - 유일한 차이는
class
의 디폴트 접근 지시자(Access Identifier)가private
인 반면struct
의 디폴트 접근 지시자는public
이다. - Google Style Guide에서는 데이터를 옮기는 목적의 passive object(다른 객체와 상호작용하지 않는 객체)에만
struct
를 사용한다고 한다.
Object In Memory(Object Alignment)
- 클래스 내부 멤버변수를 어떻게 배치하는지에 따라 메모리 크기가 달라진다.
- 객체 정렬 법칙(Object Alignment Rules)
- 각 멤버 변수들은 자신들의 사이즈의 배수의 위치에서 시작되어야 한다. (4byte면 4의 배수에서)
- 클래스의 전체 사이즈는 멤버 변수 중 가장 큰 사이즈의 배수에서 끝이 나야한다. (8byte면 8의 배수에서)
- 해당 규칙을 맞추기 위해 메모리 사이에 빈 공간(padding)이 존재할 수 있다.
- 최적화를 위해서(병렬 프로그래밍을 위해 객체의 사이즈가 32의 배수가 되어야할 경우)
alignas
지정자를 사용해서 객체의 사이즈를 정해줄 수 있다. (padding을 추가)
class Cat {
public:
void speak();
private:
double a; // 8byte
int b; // 4byte
int c; // 4byte
};
Cat cat;
sizeof(cat); // 이때 사이즈는 16byte
// (8byte) - (4byte) - (4byte) : 16byte
class Cat {
public:
void speak();
private:
int b; // 4byte
double a; // 8byte
int c; // 4byte
};
Cat cat;
sizeof(cat); // 이때 사이즈는 24byte
// (4byte) - (4byte padding) - (8byte) - (4byte) - (4byte padding) : 24byte
정적 멤버 함수(Static Member Function)
- 정적 멤버 함수는 객체를 생성하지 않아도 호출할 수 있다.
- 기본적으로 클래스의 멤버 변수, 함수들은 객체 자신의 주소를 가리키는
this
키워드와 바인딩 되어 있다. - 하지만
static
지정자가 있는 멤버 함수는this
키워드와 바인딩되어 있지 않아서 객체 생성 없이 사용 가능하다. - 대신 내부에서
this
키워드를 사용해야 쓸 수 있는 멤버 변수, 멤버 함수를 사용하지 못한다.
class Cat {
public:
void speak() {
std::cout << "speak" << std::endl;
}
static void staticSpeak() {
std::cout << "static speak" << std::endl;
}
private:
int _age;
}
Cat::staticSpeak(); // 객체를 생성하지 않고 호출 가능
Cat cat;
cat.speak();
cat.staticSpeak(); // 멤버 함수니까 당연히 객체에서도 호출할 수 있다.
정적 멤버 변수(Static Member Variable)
- 멤버 변수를
static
선언하면 이는 해당 클래스로 생성되는 객체들이 모두 공유할 수 있는 변수라는 뜻이다. - 스택이나 힙 영역에 저장되는 것이 아닌 데이터 영역에 저장된다.
class Cat {
public:
void speak() {
count++;
std::cout << count << std::endl;
}
private:
static int count; // public으로 두면 외부에서 변경이 가능하다.
int _age;
}
Cat::count = 0; // 프로그램이 실행되기 전에 초기화를 시켜줘야 한다.
Cat kitty;
Cat nabi;
kitty.speak(); // 1
nabi.speak(); // 2
// 이렇게 하면 멤버 변수에 포함시킬 필요없이 static 변수를 사용할 수 있다.
// 초기화 시점은 달라질 수 있다.
class Cat {
public:
void speak() {
static int count = 0;
count++;
std::cout << count << std::endl;
}
private:
int _age;
}
생성자 초기화 리스트(Member Initialized List)
- 생성자 호출과 동시에 멤버 변수를 초기화 하는 방식
(생성자 이름) : var1(arg1), var2(arg2) {}
- 생성자 초기화 리스트를 사용하면 생성과 초기화를 동시에 수행한다.
- 상수와 참조자는 생성과 동시에 초기화 되어야 하기 때문에 사용하기 적합하다.
- 멤버 변수가 클래스일 경우 불필요한 디폴트 생성을 할 필요가 없는 장점이 있다.
class Marine {
public:
Marine();
private:
int hp;
int coord_x, coord_y;
const int damage;
bool is_dead;
Cat cat;
}
// 기존 생성자
// 생성을 하고 그 다음 멤버 변수에 대입
Marine::Marine() {
hp = 50;
coord_x = coord_y = 0;
damage = 5;
is_dead = false;
// 생성자가 호출되기 전에 이미 cat은 디폴트 생성자를 호출한다.
}
// 생성자 초기화 리스트
// 생성과 초기화를 동시에 수행
// cat은 디폴트 생성자를 호출하지 않고 바로 5를 인자로 하는 생성자를 호출한다.
Marine::Marine() : hp(50), coord_x(0), coord_y(0), damage(5), is_dead(false), cat(5) {}
중괄호 초기화(Uniform Initialization)
- C++11부터 생성자 호출과 함수 정의를 혼돈하지 않기 위해
()
대신{}
를 사용하는 중괄호 초기화를 지원한다. - 중괄호 초기화 사용 시 장점
- 새로운 객체 생성 표현 가능
- e.g.
std::vector
에서 사용하는std::initialize_list
생성자
- e.g.
- non-static 멤버 변수 초기화
- C++11부터는
=
로 초기화해도 문제 없다.
- C++11부터는
- 복사할 수 없는 객체 초기화
- e.g.
std::atomic
도 초기화할 수 있다.
- e.g.
- narrow conversion 방지
- 암시적 형변환을 컴파일 에러로 감지한다.
- most vexing parse에서 자유로움
- e.g.
A a();
와 같이 객체를 생성하는 것인지 함수를 정의하는 것인지 혼동을 주지 않는다.
- e.g.
- 새로운 객체 생성 표현 가능
- 중괄호 초기화 사용 시 주의할 점
auto
와 함께 사용하면std::initializer_list
로 타입이 추론된다.- 생성자에
std::initializer_list
가 매개변수로 존재하면 오버로딩 우선순위가 매우 높다. std::initializer_list
생성자와 다른 생성자가 다르게 구현되었을 경우에 대해 고려해야 한다.
A a(); // A 클래스 타입을 반환하는 a 함수 정의
A a{}; // A 클래스 타입을 가지는 a 객체 생성
// vector에서는 생성자에 std::initializer_list를 써서 {} 사용에 주의해야 한다.
std::vector<int> v1(10, 20); // 10의 값을 가지는 20개의 원소 생성
std::vector<int> v2{10, 20}; // 10, 20 값을 가지는 2개의 원소 생성
초기화 리스트(Initializer List)
- C++11부터 중괄호 초기화를 사용할 때
{}
안의 데이터를 인자로 받을 수 있는std::initializer_list
가 추가되었다.
A(std::initializer_list<int> n) {}
A a = {1, 2, 3};
복사 생성자(Copy Constructor)
- 복사 생성자의 목적은 객체의 멤버 변수 값들을 복사하여 새로 메모리를 할당하는 것이다.
- 복사 생성자는
T(const T& a);
구조를 가지고 있다. - 만약 클래스가 멤버 변수로 포인터를 가지고 있으면 디폴트 복사 생성자를 사용할 경우 문제가 발생할 수 있다.
- 디폴트 복사 생성자를 사용하면 복사된 객체가 동적으로 할당한 변수의 주소값만 복사하여 같은 메모리를 두 개의 포인터가 가리키게 된다.
- 이는 객체가 파괴될 때 메모리 해제가 두 번 발생하는 문제가 발생 한다.
- 이렇게 단순히 대입만 해주는 복사를 얕은 복사(Shallow Copy)라고 한다.
- 이를 해결하기 위해서는 복사 생성자를 만들어 변수에 새로 메모리를 할당해 주면 된다.
- 이를 깊은 복사(Deep Copy)라고 한다.
// 빈 공간을 할당해서 복사를 해야된다. (임시 객체 메모리는 소멸자로 해제한다.)
MyString::MyString(const MyString &str) {
std::cout << "복사 생성자 호출 ! " << std::endl;
string_length = str.string_length;
memory_capacity = str.string_length;
string_content = new char[string_length];
for (int i = 0; i != string_length; ++i)
string_content[i] = str.string_content[i];
}
이동 생성자(Move Constructor)
- 이동 생성자의 목적은 객체의 멤버 변수 주소값을 가져오는 것이다. (말그대로 옮기는 것)
- 이동 생성자는
T(T&& a);
구조를 가지고 있다. - 멤버 변수로 포인터가 있으면 포인터 주소를 넘겨주고 넘겨준 객체는 포인터가
nullptr
가 되어야 한다. - 즉, 소유권을 이전한다고 표현할 수 있다.
- 객체를 C++ 컨테이너(ex.
std::vector
)에 넣을 때는 이동 생성자에 반드시noexcept
로 명시해야 한다.- 안그러면 컨테이너 내부에서 재할당이 발생할때 이동 연산이 아닌 복사 연산을 수행되어 성능이 떨어진다.
// 문자열 전체를 복사할 필요 없이 임시 객체가 기리키고 있던 문자열로 바꿔주면 된다.
MyString::MyString(MyString&& str) {
std::cout << "이동 생성자 호출 !" << std::endl;
string_length = str.string_length;
string_content = str.string_content;
memory_capacity = str.memory_capacity;
// 임시 객체 소멸 시에 메모리를 해제하지 못하도록 포인터를 뺏어야 한다.
str.string_content = nullptr;
}
복사 할당 연산자(Copy Assignment Operator)
=
(등호)를 객체를 생성할 때 쓰면 생성자가 호출되고 이미 생성된 객체끼리 쓰면 할당 연산자가 호출한다.- 복사 할당 연산자는
T& operator=(const T&);
구조를 가지고 있다. - 자기 자신을 복사 할당하는 상황은 막아야 한다.
class Cat {
public:
Cat& operator=(const Cat& cat) {
if(&cat == this){
return *this;
}
_name = cat._name;
_age = cat._age;
std::cout << "Copy Assignment" << std::endl;
return *this;
}
private:
std::string _name;
int _age;
};
이동 할당 연산자(Move Assignment Operator)
- 이동 할당 연산자는
T& operator=(T&&);
구조를 가지고 있다. - 자기 자신을 이동 할당하는 상황은 막아야 한다.
class Cat {
public:
Cat& operator=(Cat&& cat) noexcept {
if(&cat == this){
return *this;
}
_name = std::move(cat._name);
_age = cat._age;
std::cout << "Move Assignment" << std::endl;
return *this;
}
private:
std::string _name;
int _age;
};
특수 멤버 함수(Special Member Function)
- 특수 멤버 함수는 컴파일러가 자동으로 생성해주는 멤버 함수이다.
- 특수 멤버 함수의 자동 생성 조건은 다음과 같다. (Rule of three/five/zero)
- 디폴트 생성자
- 클래스에 사용자가 직접 선언한 생성자가 없는 경우
- 소멸자
- 클래스에 사용자가 직접 선언한 소멸자가 없는 경우
- 복사 생성자와 복사 대입 연산자
- 클래스에 사용자가 직접 선언한 이동 생성자 혹은 이동 대입 연산자가 없는 경우
- 복사 생성자와 복사 대입 연산자 중 하나를 선언해도 컴파일러가 나머지도 생성한다.
- 이동 생성자와 이동 대입 연산자
- 클래스에 사용자가 직접 선언한 소멸자, 복사 생성자, 복사 대입 연산자, 이동 생성자, 이동 대입 연산자가 없는 경우 (이 중 하나라도 있으면 자동 생성되지 않는다.)
- 이동 생성자와 이동 연산자 중 하나를 선언하면 컴파일러가 나머지를 생성해주지 않는다.
- 디폴트 생성자
- 자동 생성 조건이 아니더라도
= default
를 사용하여 특수 멤버 함수를 명시적으로 사용할 수 있다.
연산자 오버로딩(Operation Overloading)
- 연산자를 재정의해서 사용하는 방식 (C에서는 불가능했는데 C++에서 가능)
- 클래스의 멤버 함수거나 전역 함수이어야 한다.
- 멤버 함수로 오버로드된 연산자의 첫번째 인자는 무조건 해당 객체 타입이어야 한다.
class Cat {
public:
// 멤버 함수 연산자 오버로딩
bool operator==(const Cat& cat) const{
return this->getAge() && cat.getAge();
}
int getAge() const {
return _age;
};
private:
string _name;
int _age;
};
// 전역 함수 연산자 오버로딩
bool operator==(const Cat& in, const Cat& out) {
return in.getAge() == out.getAge();
}
명시적(explicit) 캐스팅
static_cast
: 일반적인 타입 변환const_cast
: 객체의 constness를 없애는 타입 변환dynamic_cast
: 파생 클래스 사이에서 다운 캐스팅reinterpret_cast
: 서로 관련 없는 포인터들끼리 캐스팅
Inheritance
상속(Inheritance)
- 자식 클래스가 부모 클래스의 멤버를 물려받아 확장이 가능한 구조를 말한다.
class A {
public:
int x;
protected:
int y;
private:
int z;
};
class B : public A {
// x is public
// y is protected
// z is not accessible from B
};
class C : protected A {
// x is protected
// y is protected
// z is not accessible from C
};
// 'private' is default for classes
class D : private A {
// x is private
// y is private
// z is not accessible from D
};
상속 관계에서 생성자/소멸자 순서
- 부모 클래스의 생성자 → 자식 클래스 생성자 → 자식 클래스 소멸자 → 부모 클래스 소멸자
함수 재정의(overriding) 조건
- 부모 클래스 함수가 반드시 가상 함수이어야 한다.
- 부모 클래스 함수와 자식 클래스 함수의 이름이 동일해야 한다. (소멸자는 예외)
- 부모 클래스 함수와 자식 클래스의 함수의 매개변수 타입이 동일해야 한다.
- 부모 클래스 함수와 자식 클래스 함수의 상수성(constness)가 동일해야 한다.
- 부모 클래스 함수와 자식 클래스 함수의 반환 타입과 예외 지정이 동일해야 한다.
- 부모 클래스 함수와 자식 클래스 함수의 참조 한정사(reference qualifier)가 동일해야 한다.
동적 다형성(Dynamic Polymorphism)
- 부모 클래스의 포인터를 만들고 런타임에 부모 클래스나 자식 클래스를 동적 할당하는 방식이다.
- 하나의 클래스 포인터로 여러 클래스를 가리킬 수 있다. (동적 다형성)
- 컴파일 타임이 아닌 런타임에 클래스를 정할 수 있다.
- 상속과
virtual
지정자가 필수이다.- 컴파일러는 포인터 타입을 보고 호출할 함수를 결정하기 때문에 부모 클래스에
virtual
지정자를 사용하여 가상 테이블(virtual table)을 가리키는 포인터를 생성해야 한다.- 가상 테이블 : 컴파일러가 호출해야 하는 함수들의 주소가 들어있는 테이블이다.
- 가상 테이블이 없으면, 부모 클래스 포인터로 자식 클래스를 가리킬 때 자식 클래스의 메서드나 소멸자를 호출할 수 없다.
- 컴파일러는 포인터 타입을 보고 호출할 함수를 결정하기 때문에 부모 클래스에
class Animal {
public:
virtual void eat() {};
virtual void sleep() {};
virtual ~Animal() = default;
private:
std::string _name;
}
class Cat : public Animal {
public:
void eat() override {};
void sleep() override {};
void jump();
private:
int _age;
}
// Animal 데이터와 virtual table 포인터를 가진 크기
Animal* ani = new Animal();
ani->eat();
ani->sleep();
// Animal 데이터 + Cat 데이터, virtual table 포인터를 가진 크기
// Animal 포인터를 사용하기 때문에 Animal 클래스에 없는 메서드는 호출할 수 없다.
Animal* cat = new Cat();
cat->eat();
cat->sleep();
cat->jump(); // 불가능, Animal 포인터는 해당 메서드를 모른다.
추상 클래스(Abstract Class)
virtual
지정자를 붙이고 뒤에= 0
을 붙이면 순수 가상 함수(pure virtual function)라 부른다.- 순수 가상 함수가 있는 클래스를 추상 클래스라고 부른다.
- 추상 클래스는 객체 생성을 못한다. (상속을 통해 사용해야 한다.)
인터페이스(Interface)
- C++에서는
Interface
지정자가 없다. 따라서 순수 가상 함수만 가지고 있는 추상 클래스를 인터페이스라 부르는 경우도 있다. - 좋은 인터페이스는 멤버 변수와 구현이 없는 것이 좋다.
다중 상속(Multiple Inheritance)
- C++에서는 클래스를 다중 상속 받을 수 있다.
다이아몬드 상속(Diamond Inheritance)
- 다중 상속이 가능하면 당연히 같은 부모 클래스를 가진 두 자식 클래스를 만들고 이 두 자식 클래스를 다중 상속받는 자식 클래스를 만들어 다이아몬드 상속을 받을 수 있다.
- 이때 의도하지 않게 제일 위의 부모 클래스가 두번 호출될 수 있다.
- 이러한 문제를 막기 위해 같은 부모 클래스를 가지는 두 자식 클래스에
virtual
지정자를 붙여서 부모 클래스가 두번 호출되는 걸 막을 수 있다.
Dynamic Cast
- 업 캐스팅(Up Casting) : 자식 클래스에서 부모 클래스로 변환
- 다운 캐스팅(Down Casting) : 부모 클래스에서 자식 클래스로 변환
dynamic_cast
를 통해 클래스를 변환할 수 있다.dynamic_cast
를 사용하면 virtual table을 확인해서 해당 타입으로 변환이 가능한지 확인하고 불가능하면nullptr
을 반환한다.- 이러한 캐스팅을 RTTI(Run Time Type Information)이라고 하는데 이를 사용하지 않는 것이 좋다고 한다.
// Animal이라는 부모 클래스를 Dog, Cat이라는 자식 클래스가 상속 받았다고 가정한다.
// Animal은 공통 함수 walk()을 가지고 있고 Dog, Cat은 각각 bark(), meow() 함수가 있다고 가정한다.
Animal* animal = new Dog();
animal->walk();
animal->bark(); // 불가능, 포인터 형이 Animal
// 이렇게 가능하지만 매우 위험한 방법
Dog* dog = static_cast<Dog*>(animal);
dog->walk();
dog->bark();
// dynamic cast를 쓰면 부적절한 변환으면 nullptr을 반환한다.
// Animal 객체를 동적 할당해놓고 Cat, Dog로 다운 캐스팅하거나
// Dog 객체를 동적 할당해놓고 Cat으로 캐스팅하면 nullptr
Cat* cat = dynamic_cat<Cat*>(animal);
if(cat == nullptr) {
std::cout << "error" << std::endl;
}
상속에서 발생할 수 있는 문제점
- 이 두 가지 문제는 인터페이스(순수 가상 함수로만 이루어진 클래스)로 구현하면 발생하지 않는다.
- Object Slicing
- 자식 클래스 객체를 생성하고 부모 클래스 레퍼런스로 가리키면 문제가 없는데 그냥 부모 클래스로
=
(할당)을 해버리면 자식 클래스에 있는 내용이 잘려나간다. - 그 이유는 부모 클래스가 자식 클래스를 복사 생성하는 과정에서 부모 클래스가 가질 수 있는 정보만 복사되기 때문이다. (virtual table은 복사되지 않는다.)
- 이를 방지하기 위해 복사 생성자를 delete하는 방법이나 protected로 보내는 방법이 있다. (문제는 같은 클래스 간의 복사 생성도 막는다.)
- 자식 클래스 객체를 생성하고 부모 클래스 레퍼런스로 가리키면 문제가 없는데 그냥 부모 클래스로
// Cat은 meow()라는 함수를 가지고 있다.
Cat cat = new Cat();
Animal& aniR = cat;
ani->meow();
Animal aniC = cat; // 복사 생성자가 호출되어 Animal 정보만 복사한다.
ani->meow(); // 에러 발생
- Operator Overloading
- 만약 부모 클래스에 대한 연산자만 정의되어 있으면 자식 클래스끼리 연산을 하면 알아서 암시적 형 변환되어 부모 클래스 연산자를 사용하여 의도치 못한 결과를 만든다.
- 추상 클래스에서 연산자를 구현하지 않고 상속 받은 클래스에서 구현하는 것을 추천한다.
bool operator==(Animal& first, Animal& second) {
return first.eyes == second.eyes;
}
Cat cat1 = new Cat{10};
Cat cat2 = new Cat{9};
if(cat1 == cat2) cout << "same" << endl; // 호출된다. (둘다 상속받은 부모 클래스 변수가 같다.)
// 자식 클래스 연산자가 없어 암시적 형변환되어 부모 클래스 연산자를 사용한다.
// 자식 클래스에 대한 연산자도 추가해줘야 한다.
bool operator==(Cat& first, Cat& second) {
return first.age == second.age;
}