분류 전체보기

728x90

Class의 상속과정에서 일어날 수 있는 문제 중의 하나인 Object Slicing에 대해서 알아본다. 그와 더불어 Operator Overloading에서 일어날 수 있는 문제에 대해서도 배워보자. 

 

 

상속으로 인한 Onject Slicing (오브젝트 슬라이싱) 문제점

 

Object Slicing이란 말 그래도 오브젝트 즉 객체가 Slicing 되는 것이다. 어떻게 해야지 Slicing이 되는지 Slicing이 되면 무슨 문제가 생기는지 아래 코드를 통해서 설명한다. 

#include <iostream>

class Animal
{
public:
	Animal() = default;
	virtual void speak()
	{
		std::cout << "Animal" << std::endl;
	}
	virtual ~Animal() = default;
private:
	double animalData = 0.0f;
};

class Dog : public Animal
{
public:
	Dog(double data) : dogData{ data } {};
	void speak() override
	{
		std::cout << "woof~" << std::endl;
	}
private:
	double dogData;
};

int main()
{
	Dog  choco{ 9.0 };

	Animal cloneChoco = choco;

	return 0;
}

Animal 클래스에는 멤버 변수 animalData, Dog 클래스에는 멤버 변수 dogData가 존재하고 main함수에서 Dog 객체인 choco를 선언하고 dogData를 9.0으로 초기화해주었다. 그다음 Animal 객체 cloneChoco를 Copy Constructor를 이용하여 Choco를 복사하여 객체를 생성하였다. 이 과정에서 생기는 문제가 바로 Object Slicing이다. 

 

아래 그림은 Object Slicing이 일어나는 이유에 대해서 그린 그림이다. 

Animal 객체인 cloneChoco에 choco오브젝트를 Copy Constructor로 복사할 경우 내부 Memory Layout에서 각 변수들이 어떻게 복사되는지 나타낸 그림이다. 위 그림에서 보면 Animal객체인 cloneChoco는 dogData를 가지고 있지 않기 때문에 dogData는 잘리게 된다. 또한 Vitual Table을 가리키는 특수한 변수 *vT는 복사가 이루어지지 않는다. 특히 아래 같이 함수를 정의한 경우 Object Slicing이 많이 일어난다. 

void print_object(Animal other) const
{
	std::cout << other.print() << std::endl;
}

위와 같이 함수 파라미터를 Animal형으로 정의를 할 경우 결국엔 Copy Assignment가 일어나기 때문에 Animal객체에 Dog객체를 Copy 한 것과 동일하게 Object Slicing을 초래하게 된다. 

 

위 두 경우의 문제를 해결하는 방법은 포인터 혹은 레퍼런스를 이용하여 해당 객체를 가리키는 방법이다. 

 

더 나아가서 Copy Constructor와 Copy Assignment를 delete를 통하여 아예 방지하는 방법도 있다. 아래 코드는 delete를 통한 Conpy Constructor와 Assignment를 사용 못하게 하는 방법이다.

#include <iostream>

class Animal
{
public:
    Animal() = default;
    Animal& operator=(const Animal& ohter) = delete;
    Animal(const Animal& other) = delete;
    virtual void speak()
    {
        std::cout << "Animal" << std::endl;
    }
    virtual ~Animal() = default;
private:
    double animalData = 0.0f;
};

class Dog : public Animal
{
public:
    Dog(double data) : dogData{ data } {};
    void speak() override
    {
        std::cout << "woof~" << std::endl;
    }
private:
    double dogData;
};

int main()
{
    Dog choco{ 9.0 };
    
    Animal cloneChoco = choco; //ERROR
    
    Dog cream{ choco }; //ERROR
    
	return 0;
}

위와 같은 해결방법은 Base 클래스에서 직접적으로 Copy Constructor와 Copy Assignment를 delete 해줌으로 Animal 객체를 Dog객체로 생성하는 것을 방지해줬다. 하지만 또 다른 에러가 발생을 하게 되는데 그 에러는 바로 Dog객체 간의 Copy Constructor가 호출이 불가능 해진다는 점이다. 이유는 Base클래스에서 Copy Construcotr를 delete를 해주면 해당 속성이 Derived클래스에도 적용이 되기 때문이다. 그렇다면 다시 아래와 같이 수정할 수 있다. 

#include <iostream>

class Animal
{
public:
    Animal() = default;
    Animal& operator=(const Animal& ohter) = delete;
    virtual void speak()
    {
        std::cout << "Animal" << std::endl;
    }
    virtual ~Animal() = default;
protected:
    Animal(const Animal& other) = default;
private:
    double animalData = 0.0f;
};

class Dog : public Animal
{
public:
    Dog(double data) : dogData{ data } {};
    void speak() override
    {
        std::cout << "woof~" << std::endl;
    }
private:
    double dogData;
};

int main()
{
    Dog choco{ 9.0 };
    
    Animal cloneChoco = choco; //ERROR
    
    Dog cream{ choco }; //OK
    
	return 0;
}

Copy Constructor를 Dog 클래스에서 접근이 가능하고 외부에서는 접근이 불가능하게 protected키워드를 이용하여 선언을 해줌으로 문제가 해결이 가능하다. 

 

 

상속으로 인해 Operator Overloading (연산자 오버 로딩)에서 생길 수 있는 문제점

 

Animal 객체에 대해서 ==연산자를 오버 로딩을 하면 아래 함수와 같다. 

bool operator==(const Animal& lhs, const Animal& rhs)
{
    return lhs.animalData == rhs.animalData;
}

위와 같이 함수 오버 로딩을 하였을 경우 생기는 문제는 Animal레퍼런스로 Dog을 가리킬 수 있기 때문에 Dog객체 간 ==연산자를 사용할 경우 implicit 하게 위 함수를 호출하게 된다. Dog객체 간 ==연산자의 사용하는 이유의 핵심은 "dogData가 같나"를 비교하는 것이지만 본의 아니게 animalData가 같은지만 확인하는 결과가 나온다. 위를 방지하기 위해 Dog객체에 대한 ==연산자 오버 로딩을 해주어야 한다

bool operator==(const Animal& lhs, const Animal& rhs)
{
    return lhs.animalData == rhs.animalData;
}

bool operator==(const Dog& lhs, const Dog& rhs)
{
    return lhs.animalData == rhs.animalData && lhs.dogData == rhs.dogData;
}

*위 함수 모두 편의상 animalData와 dogData를 public키워드에 선언을 했다고 가정하고 짠 함수이다.

 

이렇게 상속으로 인해서 발생할 수 있는 문제 두 가지를 다루어봤다. 

 

Ref.

https://www.youtube.com/channel/UCHcG02L6TSS-StkSbqVy6Fg

728x90

Multiple Inheritance (다중 상속)으로 인해 Dianmond Problem (다중 상속 문제)를 야기할 수 있는데 Dianmond Problem과 해당 문제를 해결하는 방법에 대해서 배워보자. 

 

 

Diamond Problem (다중 상속 문제)

 

Diamond Problem은 다중으로 상속을 했을 때 나타나는 문제이다. 

하이브리드 동물 Liger를 클래스로 나타낸다면 아래 그림과 같을 것이다. 

위 그림처럼 Tiger와 Lion은 둘 다 동물이기 때문에 Animal클래스를 상속을 받고 Tiger와 Lion의 교배로 낳은 Liger는 두 동물의 상속으로 만들어진다. 위 그림을 코드화 시키면 아래와 같다. 

#include <iostream>

class Animal
{
public:
	Animal()
	{
		std::cout << "Animal Constructor\n";
	}
	virtual ~Animal() = default;
private:
	double animalData;
};

class Lion : public Animal
{
public:
	Lion()
	{
		std::cout << "Lion Constructor\n";
	}
	virtual ~Lion() = default;
private:
	double lionData;
};

class Tiger : public Animal
{
public:
	Tiger()
	{
		std::cout << "Tiger Constructor\n";
	}
	virtual ~Tiger() = default;
private:
	double tigerData;
};

class Liger : public Lion, public Tiger
{
public:
	Liger()
	{
		std::cout << "Liger Constructor\n";
	}
	virtual ~Liger() = default;
private:
	double ligerData;
};

int main()
{
	Liger miniLiger;
}

위 코드를 보면 딱히 문제점이 없어 보인다. 하지만 Liger객체를 생성하여 생성자가 호출이 되는 순서를 보게 되면 이상한 현상이 일어나게 된다. 

코드 출력

출력 화면을 보게 되면 Animal의 생성자가 2번 호출이 되는 것을 확인할 수 있다. 사실 쉽게 생각하면 Tiger와 Lion은 각각 Animal을 상속을 받았기 때문에 Animal object를 가지게 되고 또 Liger는 Tiger와 Lion을 다중 상속을 받았기 때문에 Animal 객체가 두 번 생성이 되는 것이다.  또 다른 문제는 만약 Liger 객체를 위와 같이 생성을 했다고 가정을 하면 Animal에 animalData를 접근하는 멤버 함수를 만들고 animalData를 접근하려고 한다면 생성된 두 개의 Animal객체 중 어떤 Animal객체의 멤버 함수를 호출하는가라는 문제가 또 생긴다. 

 

이러한 현상을 방지하기 위해 우리는 Virtual Inheritance라는 것을 사용하면 위 문제를 해결할 수 있다. 

 

 

Virtual Inheritance (가상 상속)

 

가상 상속이란 클래스 상속에서 "Virtual"키워드를 통하여 가상으로 상속을 받게 하는 방법이다. Virtual Function에서 Derived클래스에서 해당 Virtual Function에 대한 재정의를 기대한다고 말했는데 Virtual Inheritance에서도 동일하게 다른 클래스에서 해당 Base클래스를 상속받을 것을 기대한다고 보면 된다. 만약 다른 클래스에서 해당 Base클래스를 상속받지 않았을 경우는 자기가 받으면 문제는 해결이 된다. 

 

위에 코드를 가상 상속으로 수정한 코드는 아래와 같다. 

#include <iostream>

class Animal
{
public:
	Animal()
	{
		std::cout << "Animal Constructor\n";
	}
	virtual ~Animal() = default;
private:
	double animalData;
};

class Lion : virtual public Animal
{
public:
	Lion()
	{
		std::cout << "Lion Constructor\n";
	}
	virtual ~Lion() = default;
private:
	double lionData;
};

class Tiger : virtual public Animal
{
public:
	Tiger()
	{
		std::cout << "Tiger Constructor\n";
	}
	virtual ~Tiger() = default;
private:
	double tigerData;
};

class Liger : public Lion, public Tiger
{
public:
	Liger()
	{
		std::cout << "Liger Constructor\n";
	}
	virtual ~Liger() = default;
private:
	double ligerData;
};

int main()
{
	Liger miniLiger;
}

위와 같이 virtual 키워드를 통해서 Diamond Problem을 해결할 수 있다. 

 

 

Virtual Inheritance를 통해서 상속을 받게 되면 기존 상속과 Memory Layout이 다르다. thunk함수와 offset을 이용하여 맞는 함수를 가리키게 되는데 이 부분은 복잡하고 또한 Virtual Inhertance자체가 잘 사용이 되지 않는 기능이기 때문에 해당 Memory Layout은 영상을 참고하여 한번 보는 것으로 끝낸다. 궁금하다면 하단 레퍼런스에서 링크를 통해 보길 바란다. 

 

 

Ref.

https://www.youtube.com/watch?v=0izSeUXpwDw&list=PLDV-cCQnUlIar6Wx3rkXHs7wbfmlz34Fz&index=7&t=300s 

728x90

지금까지 다루었던 상속은 단일 상속이었다. 이번에는 다중으로 클래스를 상속받는 방법에 대해서 알아본다. 

 

Multiple Inheritance (다중 상속)

말 그대로 Base Class가 두 개 이상이 되는 것을 의미한다. 내용은 간단하니 바로 코드를 보면서 알아본다. 

 

Base 클래스가 두 개일 경우 생성자와 소멸자는 어떤 순서로 호출이 되는지를 보여주는 코드이다. 

#include <iostream>

class Lion
{
public:
	Lion()
	{
		std::cout << "Lion constructor\n";
	}
	virtual ~Lion()
	{
		std::cout << "Lion destructor\n";
	}
private:
	double lionData;
};

class Tiger
{
public:
	Tiger()
	{
		std::cout << "Tiger constructor\n";
	}
	virtual ~Tiger()
	{
		std::cout << "Tiger destructor\n";
	}
private:
	double tigerData;
};

class Liger : public Tiger, public Lion
{
public:
	Liger()
	{
		std::cout << "Liger constructor\n";
	}
	~Liger()
	{
		std::cout << "Liger destructor\n";
	}
private:
	double ligerData;
};

int main()
{
	Liger liger();
}

Tiger와 Lion을 Base클래스로 가지는 Derived클래스 Liger는 상속의 순서를 public Tiger, public Lion으로 지정을 해주었다. 그럴 경우 아래와 같이 생성자와 소멸자가 호출이 되게 된다. 

사진과 같이 앞에 있는 Tiger의 생성자가 호출이 되고 그다음 Lion 그다음 Liger의 생성자가 호출이 되는 것을 확인할 수 있다. 만약 Lion을 앞에 써주었을 경우에는 Lion의 생성자가 먼저 호출이 될 것을 유추할 수 있다. 

 

 

다중 상속의 경우 해당 클래스의 사이즈는 어떤지 알아보자. 위 코드에서 상속받은 두 클래스의 사이즈를 각각 알아보면 Lion클래스 사이는 1개의 double형 멤버 변수 8바이트와 virtual함수로 인한 virtual table을 가리키는 포인터 8바이트 총 16바이트이다. Tiger클래스 또한 double형 멤버 변수 1개와 virtual 멤버 함수로 인한 virtual table을 가리키는 포인터 8바이트 총 16바이트이다. Liger클래스에 double형 멤버 변수가 존재하니 통 40바이트이다. 

Liger object 메모리 구조

그러하면 궁금증이 두 개의 클래스를 상속을 받아 virtual table을 가리키는 포인터 두 개 존재하게 되는데 어떻게 작용하는지 궁금할 수 있다. 그것에 대해 알아보자. 

 

클래스 자체의 코드는 생략하고 main함수의 코드만 가지고 예를 들어본다. ㅇ

int main()
{
    Tiger* polyTiger = Liger();
    delete polyTiger;
    return 0;
}

지난 포스트에서 Base클래스 포인터 형으로 Derived 객체를 생성할 수 있다고 배웠다. 위 예는 두 개의 Base 클래스에서 Tiger 클래스 포인터를 이용하여 Liger객체를 생성한 코드이다. 이 경우 아래 사진과 같이 Tiger 오브젝트에 관한 메모리는 보이지 않고 Liger와 Tiger만 접근이 가능하다. 그러하기 때문에 Tiger객체에 있는 포인터가 Liger Virtual Table을 가리키게 된다. 

 

다음 예제로 Lion 클래스형 포인터로 Liger 객체를 생성하였을 때 접근 가능한 메모리와 어떠한 포인터가 Virtual Table을 가리키게 되는지를 알아본다. 

int main()
{
    Lion* polyLion = Liger();
    delete polyLion;
    return 0;
}

Lion클래스 포인터로 Liger객체를 선언했다. 아래는 해당 내용의 메모리와 Virtual Table을 어떻게 가리키게 되는지 표현하는 그림이다. 

 

 

마지막으로 Liger클래스 포인터로 Liger객체를 선언할 경우이다. 

int main()
{
    Liger* polyLiger = Liger();
    delete polyLiger;
    return 0;
}

 

위 경우는 모든 Base클래스 오브젝트가 접근이 가능하고 두 Base클래스의 포인터가 모두 Liger Virtual Table을 가리키게 된다. 

728x90

앞서 Virtual Function과 Virtual Table 그리고 Polymophism에 대해서 공부를 하였다. 이번에는 Pure Virtual Function (순수 가상 함수)에 대해서 공부한다. 

 

Pure Virtual Function (순수 가상 함수)


순수 가상 함수는 엄청 쉽다. 기존 Virtual Function에서 definition을 해주지 않고 값을 0으로 해주면 그것에 Pure Virtual Function이 된다. 또한 순수 가상 함수가 존재하는 클래스는 Abstract Class라고 부른다. 

 

아래 코드는 간단한 Pure Virtual Function을 만든 예이다. 

class Human
{
public:
    virtual void walk() const = 0;
private:
    int age;
};

사람을 클래스로 만든다고 가정을 하면 사람의 공통적인 특징인 걷기가 있을 것이다. walk() 멤버 함수를 virtual로 선언한 뒤 0으로 초기화를 해주어 Pure Virtual Function을 만들었다. 

 

walk() 멤버 함수를 0으로 초기화를 하면 해당 Human클래스의 객체 생성이 불가능하다. 쉽게 생각하면 Human의 객체는 walk() 멤버 함수를 호출할 방법이 없기 때문이다. 결론적으로 Class 내부에 Pure Virtual Function이 1개라도 존재한다면 해당 클래스의 객체 생성이 불가능하다. 이는 클래스가 Abstract Class가 된다는 의미이다.  

 

 

Pure Virtual Function의 상속 관계

 

Pure Virtual Function도 Virtual Function이기 때문에 상속받은 클래스에서의 재정의를 기대한다. 결국 Pure Virtual Function이 존재하는 Abstract Class를 상속받은 Class에서 해당 Pure Virtual Function을 재정의 하지 않으면 상속받은 Derived Class도 객체 생성이 불가능해진다. 결국 Derived Class를 객체 생성을 하기 위해서는 Pure Virtual Function에 대한 재정의가 반드시 이루어져야 한다. 

#include <iostream>

class Human
{
public:
    virtual void walk() const = 0;
private:
    int age;
};

class Asian
{
public:
	virtual void walk() const
    {
        std::cout << "Walking..." << std::endl;
    }
private:
    int intelligence;
}

int main()
{
    Human Human1; //Abstract Class객체 생성 불가.
    Asian gildong; //Pure Virtual Function가 재정의 되어 객체 생성 가능
    
    return 0;
}

Asian 클래스에서 Pure Virtual Function에 대한 재정의가 이루어졌기 때문에 해당 객체를 선언이 가능하다. 

 

 

Pure Virtual Function을 사용하는 이유?

 

Pure Virtual Function을 사용하는 이유는 Interface라는 개념을 사용하기 위함이다.

 

Interface

 

Interface란 Abstract Class를 의미하지만 specific 하게 멤버 변수와 Pure Virtual Function만 존재하는 Abstract Class를 의미한다. 

Interface를 사용하는 이유는 쉽게 생각하면 "틀 제공"과 같다. 위에서 구성한 클래스를 보게 되면 Human이라는 interface를 생성하였다. walk() 멤버 함수만 구현하였지만 Human 즉 사람이 가지고 있는 공통적인 특성들이 있을 것이다. 예를 들면 잠을 자는 것, 감정이 있는 것 등등. 결국 Interface가 궁극적으로 해주는 역할은 어떤 대상의 전체적인 틀을 명시해주고 Derivde클래스에서 해당 특징들에 대한 자세한 내용을 기술하여 사용하기 위함이다. 

 

개인적으로 게임의 몬스터를 만든다고 생각을 하면 이해하기 쉬웠다. 핵심은 전체적인 틀을 제공하여 세부사항은 상속받은 클래스에서 재정의하기를 기대하는 것이다. 

728x90

클래스의 크기(Memory Size)를 통해서 Virtual Table 혹은 vTable에 대해서 알아보자. 

먼저 아래 코드를 보면 

#include <iostream>

class Animal
{
public:
    void speak()
    {
        std::cout << "Animal\n"
    }
private:
    double height;
};

int main()
{
    std::cout << sizeof(Animal) << '\n';
    return 0;
}

*double의 크기는 8바이트로 간주

main함수에서 sizeof() 연산자를 이용하여 Animal클래스의 크기를 출력을 해보면 8바이트가 출력이 된다. 이는 Animal클래스가 private 하게 가지고 있는 double형 멤버 변수 height에 의한 것이다.

 

어쩌면 의문이 드는 것이 spaek() 함수에 대한 메모리는 저장이 되지 않는 것인가?이다. 멤버 함수는 일반 함수와 동일하게 "code"영역에 저장이 되게 된다. 객체가 생성될 때마다 매번 멤버 함수가 할당이 되면 비효율적이기 때문에 "code"영역에 저장하고 공유를 한다. 

 
저번 포스팅에서 공부한 Virtual Function을 이용하여 상속이 될 경우 클래스의 크기는 어떻게 변할까?

class Animal
{
public:
	virtual void speak()
	{
		std::cout << "Animal" << std::endl;
	}
private:
	double hetght;
};

class Dog : public Animal
{
public:
	virtual void speak() override
	{
		std::cout << "Woof" << std::endl;
	}
private:
	double weight;
};

int main()
{
	std::cout << "Animal size : " << sizeof(Animal) << std::endl;
	std::cout << "Dog size : " << sizeof(Dog) << std::endl;
	return 0;
}

위 코드를 실행시켜볼 경우 Animal 클래스의 사이즈는 16바이트, Dog 클래스의 사이즈는 26바이트로 출력이 된다. 이는 Virtual Function을 사용하여 Virtual Table이 생성이 되고 이를 가리키는 pointer가 객체의 메모리 영역에 생기기 때문이다. 

 

이해하기 쉽게 아래 그림으로 클래스에서 어떻게 Virtual Function이 호출이 되는지 그림으로 나타냈다. 

 

 

Dynamic Polymorphism (동적 다형성)

프로그래밍에서 말하는 다형성은 사실 생물학에서 가져온 용어이다. 동일한 종(species) 중에서도 다양한 변이를 가지는 현상을 만한다. C++에서는 Polymorphism은 동일한 객체 혹은 함수가 특정 상황에서는 다른 게 작동하는 것을 의미한다. 우리는 이미 이런 특성을 공부하지 않았는가? 결국엔 객체들이 Polymorphism을 가지게 하기 위해서는 Virtual Function을 Override 함으로써 다형성을 얻을 수 있다. 

 

그럼 여기서 Dynamic은 무엇을 뜻하느냐? Polymorphism을 Compile-time이 아닌 Run-time에 얻는 것을 의미한다. C++ 같은 객체지향 언어는 상속이 이루어진 클래스의 Base클래스 포인터 타입에 Derived 클래스의 주소를 가리키는 것이 가능하기 때문에 동적으로 다형성을 취득할 수 있다. 

 

아래 코드는 Virtual Function으로 Polymorphism을 가지게 하고 Animal 포인터 타입 변수로 사용자의 입력에 따라 동적으로 객체를 생성하게 하여 Dynamic Polymorphism을 얻게 한 코드이다. 


#include <iostream>

class Animal
{
public:
	virtual void speak() const
	{
		std::cout << "Animal" << std::endl;
	}
private:
	double hetght;
};

class Dog : public Animal
{
public:
	virtual void speak() const override
	{
		std::cout << "Woof" << std::endl;
	}
private:
	double weight;
};

int main()
{
	Animal* animalPtr = nullptr; 	//Animal *형 변수를 만들어 null pointer로 초기화
	int i;
	std::cout << "which one do you want to create?\n1. Animal\n2. Dog\n";
	std::cin >> i;

	if (i == 1)
	{
		animalPtr = new Animal();
	}
	else if (i == 2)
	{
		animalPtr = new Dog();
	}
	//사용자가 1을 넣으면 Animal객체가 생성이 되고 2를 입력하면 Dog객체가 생성이 된다
   	//동적으로 만들어진 객체는 Heap영역에 생성이 되고 stack영역에 있는 animalPtr이 객체를 가리키게 된다. 
	animalPtr->speak();

	delete animalPtr;	//동적할당 메모리 해제
	return 0;
}

 

 

Ref.

https://nocodeprogram.com/lecture/1/78474/

https://micropilot.tistory.com/3072

728x90

이번 포스팅에서는 Virtual Funcction (가상 함수) 사용법을 포스팅한다. 

 

Virtual Function (가상 함수)

가상 함수란 상속되는 자식 클래스에서 재정의 될 것으로 예상을 하는 멤버 함수이다. 이런 가상 함수는 Dynamic Polymorphism에 의한 동적 타입에 의해서 결정이 된다. 

 

다음 코드는 일반 멤버 함수를 virtual function으로 정의한 예시를 보여주는 코드이다. 

#include <iostream>
class Animal
{
public:
    void print()
    {
        std::cout << "I am Animal" << std::endl;
    }
    virtual void speak()
    {
        std::cout << "Animal" << std::endl;
    }
};

class Dog : public Animal
{
public:
    void print()
    {
        std::cout << "I am Dog" << std::endl;
    }
    void speak() override
    {
        std::cout << "woof" << std::endl;
    }
};

int main()
{
    Animal* Ptr1;
    Animal* Ptr2;
    
    Ptr1 = new Dog();
    Ptr2 = new Animal();
    
    Ptr1->print();
    Ptr1->speak();
    
    Ptr2->print();
    Ptr2->speak();
}

 

 

출력

위 코드에서 먼저 Animal *형 변수  Ptr1과 Ptr2를 생성하였다. Ptr1은 Dog객체를, Ptr2는 Animal객체를 가리킨다. 클래스에서 각 클래스에서 print()는 non-virtual 멤버 함수이며 speak()는 virtual 멤버 함수이다. 각 포인터 변수의 타입은 Animal* 타입이기 때문에 어떤 클래스 객체를 가리키고 있던 print() 멤버 함수는 non-virtual 하기 때문에 Animal클래스의 print() 멤버 함수를 호출하게 된다. 하지만 virtual 함수인 speak() 멤버 한수는 가리키고 있는 객체에 speak() 멤버 함수가 재정의 되어있다면 재정의된 함수를 호출하게 된다. 

728x90

Class에는 Access Specifier (접근 제한자)가 존재한다. 상속에 관련된 내용을 공부하기 전에 접근 제한자와 상속관계의 접근 제한자에 대해서 포스팅한다. 

 

먼저 기본적인 클래스의 접근 제한자에 대해서 알아보면 접근 제한자는 총 3가지가 존재한다. public, protected, private이 있다. 

 

Public

public 접근 제한자에 선언된 멤버 변수 혹은 멤버 함수는 어디에서든지 접근이 가능하다.

 

Protected

protected에 선언된 멤버 함수 혹은 멤버 변수는 클래스 내부와 상속된 클래스에서 섭근이 가능하다. 

 

Private

private에 선언된 멤버 변수 혹은 멤버 함수는 오직 클래스 내부에서 접근이 가능하다. 

 

*접근 권한자를 명시해주지 않을 경우 private으로 간주한다. 

 

#include <iostream>

class Dog
{
public:
	Dog() = default;
	Dog(int age) : mAge{ age } {};
	void public_print() const
	{
		protected_print();
	}
protected:
	void protected_print() const
	{
		std::cout << "The dog is " << mAge << " years old\n";
	}
private:
	int mAge;

};

int main()
{
	Dog choco{ 9 };

	choco.public_print();
}

위 코드에서 클래스를 먼저 보게 되면 public, protected와 private 접근 제한자가 모두 사용된 것을 볼 수 있다. 먼저 private을 보면 integer형 멤버 변수 mAge가 선언되었다. 해당 멤버 변수는 클래스 내부에서만 접근이 가능하다. 다음으로 protected를 보게 되면 protercted_print() 멤버 함수가 존재한다. 이 멤버 함수에서는 private에 mAge를 불러와 출력을 하는 함수이다. protected 또한 현재 상속된 클래스가 존재하지 않기 때문에 클래스 내부에서만 접근이 가능하다. 마지막으로 public을 보면 기본 생성자와 integer 변수를 파라미터로 가지는 생성자가 있다. 그다음으로 public_print() 멤버 함수가 존재한다. 해당 멤버 함수에서 protected에 존재하는 protected_print() 멤버 함수를 호출하게 되는 멤버 함수이다. 

 

결론적으로 main함수에서 choco 객체를 생성하고 public에 있는 public_print()를 호출하고 그다음 protected에 protected_print() 함수를 호출하여 private에 있는 mAge를 불러와 출력을 하게 된다. 

 

 

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
};

class D : private A    // 'private' is default for classes
{
    // x is private
    // y is private
    // z is not accessible from D
};

위 코드는 Stack overflow의 "Difference between private, public and protected inheritance" 질문에서 발췌한 코드이다.

 

상속을 받기 위해서 어떻게 상속을 받을 것이냐를 정해주는 것이 접근 제한자이다. 각 접근 제한자에 대해서 상속받은 클래스 B, C, D에 대해서 A클래스의 멤버들의 접근 권한을 코드의 주석으로 나타냈다.

 

 

Ref.

https://stackoverflow.com/questions/860339/difference-between-private-public-and-protected-inheritance

728x90

클래스를 사용하면서 자주 보게 되는 키워드들에 대해서 알아보자. 

 

const

먼저 const는 constant의 줄임말로 상수 취급을 해준다는 말이다. 즉 값을 바꾸는 것을 방지한다고 보면 된다. const 키워드는 C++ 개발을 안전하게 해 준다. 가능하면 붙여줄 수 있는 곳에 다 붙여주는 것이 좋다. 아래 코드는 const의 사용 예제이다. 

#include <iostream>
#include <string>

class Dog
{
public:
    Dog(std::string) : mName{std::move(name)} {};
    void speak() const
    {
        std::cout << mName << std::endl;
    }
private:
    std::string mName;
    
}

int main()
{
    const Dog choco{"choco"};
    choco.speak();
}

Dog클래스의 멤버 함수인 speak() 함수를 보면 오른쪽에 const키워드가 있는 것을 확인할 수 있다. 이는 멤버 함수를 읽기 전용 함수로 만들어주는 키워드 이기 때문에 함수 내부에서 객체의 멤버 변수에 대해서 수정이 불가하다. 또한 main함수에서 객체 자체를 const객체로 선언을 하였기 때문에 객체의 멤버 변수에 대해서 재정의가 불가하다. 당연하게 만약 const로 선언이 된 객체에서 비 const멤버 함수를 호출하는 것은 불가하다. 쉽게 생각하면 애초에 객체부터 상수화 된 객체인데 멤버 함수에서 수정할 여지가 있다면 안되기 때문이다. 

 

하지만, const 멤버 함수에서 멤버 변수에 대한 수정이 가능한 경우가 있다. 바로 mutable키워드이다. 

만약 클래스의 멤버 변수가 mutable로 선언이 된 경우에는 const멤버 함수 내에서 수정이 가능하다.

#include <iostream>
#include <string>

class Dog
{
public:
    Dog(std::string) : mName{std::move(name)} {};
    void speak() const
    {
        mName = "cream";
        std::cout << mName << std::endl;
    }
private:
    mutable std::string mName;
}

int main()
{
    const Dog choco{"choco"};
    choco.speak();
    //cream
}

위와 같이 mutable로 선언된 string객체가 const멤버 함수 speak() 내에서 수정이 된 것을 확인할 수 있다. 하지만, 이렇게 사용 가능한 것임에도 불구하고 mutable키워드로 멤버 변수를 선언하는 것을 지양해야 한다. 왜냐하면 우리가 const키워드를 사용하는 이유는 멤버 변수의 수정을 막기 위함이다. const를 붙여줌으로써 컴파일러가 멤버 변수의 수정이 불가하다는 것을 인지하고 수정이 될 경우 오류가 있다고 알려주지만 mutable의 경우는 const로 선언한 것을 무시를 해주는 것이기 때문에 지양을 해야 한다. 

 

 

explicit

explicit키워드는 C++에서 implicit conversion을 막기 위함에 있다. expllicit키워드는 주로 constructor에서 사용이 된다.

#include <iostream>
#include <string>

class Cat
{
public:
	explicit Cat(int age) : mAge{ age } {};
	Cat(std::string name, int age) : mName{ std::move(name) }, mAge{ age } {};

private:
	int mAge;
	std::string mName;
	mutable std::string changable = "initial";			
};

int main()
{
    Cat choco = 3; //error
    Cat choco{3};
}

 Cat 클래스에서 interger variable을 1개 받는 Constructor를 보게 되면 explicit키워드가 있다. 이 의미는 main함수에서 명확하게 확인할 수 있다. 만약 객체에 integer값은 대입 연산자로 대입을 하게 될 경우 integer 상수 3이 implicit class-type consversion이 일어나게 된다. 이 뜻은 임시 객체에 3이라는 값을 저장하고 다시 임시 객체를 choco객체에 복사를 하괴 되는 과정이 일어난다. 이는 많은 메모리 손실을 야기하므로 explicit 하게 말 그대로 Argument를 명백하게 curly brace에 넣어서 넘겨줘라 라고 생각하면 쉽다. 

 

통상 클래스를 구성할때 Argument가 1개인 Constructor는 explicit키워드를 넣어주어 implicit conversion을 방지하는 것이 좋다. 

728x90

Overloading (오버 로딩)에는 Function Overloading과 Operator Overloading이 있다. 

 

 

Function Overloading  (함수 오버 로딩)

Function의 이름이 같고 파라미터가 다를 때 name magling을 이용하여 argument에 따라 맞는 함수를 호출해주는 것을 말한다. Function Overloading에는 Static Polymorphism (정적 다형성)과 Dynamic Polymorphism (동적 다형성)이 있다. Static Polymorphism은 함수가 Compile-time에 binding이 되는 것을 말한다. Dynamic Polymorphism은 함수들이 Run-time에 바인딩이 되는 것이다. 주로 virtual 키워드를 통해 만들어 낸다. 해당 내용은 Class의 Inheritance (상속) 포스팅에서 자세히 다루도록 한다. 

 

함수 오버 로딩을 구현한 코드와 함께 설명을 하겠다. 

#include <iostream>

void function(int n)
{
    std::cout << "function(int)" << std::endl;
}

void function(double d)
{
    std::cout << "function(double)" << std::endl;
}

int main()
{
    function(3);
    function(3.18);
}

위 코드는 함수명 function인 함수를 오버 로딩한 코드이다. main함수에서 각각 integer형 상수와 double 상수를 Argument로 주고 호출을 하면 Argument에 따라서 맞는 함수를 호출하게 된다.  위 코드를 어셈블리 코드로 변환을 해서 보면 더 명확하게 확인할 수 있다. 

어셈블리 코드를 보게 되면 function(int)는 _Z8functioni, function(double)은 _Z8functiond로 name mangling에 의해 변환이 되었다. 그리고 main함수를 보게 되면 각각 다른 함수를 호출하는 것을 확인할 수 있다. 

 

 

Operator Overloading (연산자 오버 로딩)

Operator Overloading은 말 그래로 operator (연산자)에 대해서 overloading을 한다. 예를 들면 산술 연산자 (+, -, etc.), 비교 연산자 (>, <, ==)과 같은 연산자들에 대해서 overloading을 하는 것을 말한다. Operator Overloading이 중요한 이유는 특정 연산자는 STL과 연계하여 강력한 C++ 개발을 지원하기 때문이다. 

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
class Dog
{
public:
    Dog(std::string name, int age) : mName{ std::move(name) }, mAge{ age }{};
    const std::string& name() const
    {
        return mName;
    }
    int age() const
    {
        return mAge;
    }
    void print() const
    {
        std::cout << mName << " " << mAge << std::endl;
    }

private:
    std::string mName;
    int mAge;
};

bool operator==(const Dog& lhs, const Dog& rhs)
{
    return lhs.age() == rhs.age() && lhs.name() == rhs.name();
}

bool operator<(const Dog& lhs, const Dog& rhs)
{
    if (lhs.age() < rhs.age())
        return true;
    return false;
}

std::ostream& operator<<(std::ostream& os, const Dog& c)
{
    return os << c.name() << " " << c.age();
}

int main()
{
    Dog choco{"choco", 10};
    Dog cream{"cream", 5};
    std::vector<Dog> dogs;
    // add dogs....
    std::sort(dogs.begin(), dogs.end());
    //sort를 하는 기준은  comparison operator이 된다. 
    
    std::cout << choco == cream << std::endl; //0
    
    std::cout << cream < choco << std::endl; //1
    
    std::cout << choco;
    //choco 10

}

 

쉽게 생각하면 연산자 오버로딩은 이름을 오버 로딩하고자 하는 operator'연산자'로 지정을 해주면 된다. 위 코드를 보면 쉽게 함수 연산자에 대해서 알 수 있다. 위 코드에서 comparison operator '<'를 오버 로딩을 하게 되면 STL feature인 sort함수를 Dog객체가 담긴 vector에 그대로 적용할 수 있다. 왜냐하면 sort함수는 비교 연산자를 통해서 정렬이 되게 되는데 연산자 오버 로딩을 통해 객체 간의 정렬이 이루어지게 할 수 있다. 

728x90

많은 내용을 다루다 보니 제목이 다소 이상하게 지어졌다. 본 포스트에서 다룰 내용은 아래와 같다. 

  • Constructor (생성자)
  • Destructor (소멸자)
  • Copy / Move Constructor (복사 / 이동 생성자)
  • Copy / Move Assignment (복사 / 이동 대입 연산자)

 

통상적으로 클래스를 만들 때 생성자를 만들어 주는 경우가 많다 나머지 4개의 메소드는 컴파일러가 자동으로 만들어 주기 때문에 생략하고 클래스를 구성해도 무방하다. 하지만, 리소스를 포인터로 관리하는 경우(Raw pointer)에는 컴파일러가 자동으로 만들어주는 메소드로는 사용이 안될 경우에 위 5개의 메소드를 다 직접 정의해주어야 한다. 

 

컴파일러가 만들어주는 메소드를 사용하면 안 되는 이유는 간단하다. 기본 Destructor (소멸자)를 사용하게 될 경우에는 포인터가 가리키고 있는 동적 할당된 메모리가 해제가 안되기 때문이다. 또한 Copy Construtor와 Copy Assignment가 동적 할당된 메모리 자체를 복사하는 것 (Deep copy)이 아닌 "Shallow copy"만 일어나기 때문이다. 

 

클래스를 구성할 때 아래 세 가지 룰을 따른다. 

 

Rule of Zero

클래스의 Instance로 Raw pointer가 존재하지 않게 클래스를 구성하는 방법이다. Raw pointer가 존재하지 않다면 따로 수동적으로 메모리 할당을 해제해줄 필요가 없기 때문에 5개의 멤버 함수를 다 정의해줄 필요가 없다. 

class rule_of_zero
{
    std::string cppstring;
 public:
    rule_of_zero(const std::string& arg) : cppstring(arg) {}
};

 

Rule of Three

클래스의 Instance로 Raw pointer가 존재할 경우 위에서 말한 "Shallow copy"문제와 메로리 할당을 컴파일러가 자동으로 정의해주는 소멸자에서 해제가 안되기 때문에 Destructor, Copy Constructor 그리고 Copy Assignment를 정의해주어야 한다. 

#include <cstddef>
#include <cstring>
 
class rule_of_three
{
    char* cstring; // raw pointer used as a handle to a dynamically-allocated memory block
    rule_of_three(const char* s, std::size_t n) // to avoid counting twice
    : cstring(new char[n]) // allocate
    {
        std::memcpy(cstring, s, n); // populate
    }
 public:
    rule_of_three(const char* s = "")
    : rule_of_three(s, std::strlen(s) + 1)
    {}
    ~rule_of_three() // I. destructor
    {
        delete[] cstring;  // deallocate
    }
    rule_of_three(const rule_of_three& other) // II. copy constructor
    : rule_of_three(other.cstring)
    {}
    rule_of_three& operator=(const rule_of_three& other) // III. copy assignment
    {
        if (this == &other) return *this;
        std::size_t n{std::strlen(other.cstring) + 1};
        char* new_cstring = new char[n]; // allocate
        std::memcpy(new_cstring, other.cstring, n); // populate
        delete[] cstring;  // deallocate
        cstring = new_cstring;
        return *this;
    }
 public:
    operator const char *() const { return cstring; } // accessor
};

 

 

Rule of Five

Rule of Three에서 모든 Copy가 Deep Copy가 될 필요가 없는 경우가 생길 필요가 없기 때문에 데이터의 소유권을 가져올 수 있는 Move Constructor와 Move Assignment까지 정의를 해야 한다. 

class rule_of_five
{
    char* cstring; // raw pointer used as a handle to a dynamically-allocated memory block
 public:
    rule_of_five(const char* s = "")
    : cstring(nullptr)
    { 
        if (s) {
            std::size_t n = std::strlen(s) + 1;
            cstring = new char[n];      // allocate
            std::memcpy(cstring, s, n); // populate 
        } 
    }
 
    ~rule_of_five()
    {
        delete[] cstring;  // deallocate
    }
 
    rule_of_five(const rule_of_five& other) // copy constructor
    : rule_of_five(other.cstring)
    {}
 
    rule_of_five(rule_of_five&& other) noexcept // move constructor
    : cstring(std::exchange(other.cstring, nullptr))
    {}
 
    rule_of_five& operator=(const rule_of_five& other) // copy assignment
    {
         return *this = rule_of_five(other);
    }
 
    rule_of_five& operator=(rule_of_five&& other) noexcept // move assignment
    {
        std::swap(cstring, other.cstring);
        return *this;
    }
 
// alternatively, replace both assignment operators with 
//  rule_of_five& operator=(rule_of_five other) noexcept
//  {
//      std::swap(cstring, other.cstring);
//      return *this;
//  }
};

위 코드들은 cpprefernce에서 가져온 코드이다. 더 자세한 내용은 아래 링크를 참조하길 바란다. 

https://en.cppreference.com/w/cpp/language/rule_of_three

 

The rule of three/five/zero - cppreference.com

[edit] Rule of three If a class requires a user-defined destructor, a user-defined copy constructor, or a user-defined copy assignment operator, it almost certainly requires all three. Because C++ copies and copy-assigns objects of user-defined types in va

en.cppreference.com

 

 

아래는 필자가 Raw pointer 없이 Rule of Five를 따르는 클래스이다. 

/*
한개의 메소드만 가지고 있지만 컴파일러가 알아서 메소드들을 만들어 준다 CGF - Compiler Generaterd Functions
1. constructor
2. destructor
3. copy / move constructor
4. copy / move assignment
일반적으로 생성자는 우리가 만들어 주는 경우가 많지만
아래 5개의 메소드는 일반적으로 우리가 만들지 않는다. 
*/
#include <iostream>
#include <string>

class Dog
{
public:
	Dog() = default;		//default constructor 다른 constructor를 만들고 기본 생성자를 통해 생성할 경우 오류가 남. 컴파일러가 만든것을 사용한다.
	Dog(std::string name, int age) : mName{ name }, mAge{ age } {};		//constructor

	~Dog() {};	//destructor

	Dog(const Dog& other) : mName{ other.mName }, mAge{ other.mAge }	//copy constructor
	{
		//std::memcpy();	포인터가 존재할 경우 메모리 복사
	}	

	Dog(Dog&& other) noexcept : mName{ std::move(other.mName) }, mAge{ other.mAge }	//move constructor
	{
		//mptr = other.mPtr		other이 가르키고있는 주수를 복사하고 ohter의 포인터를 nullptr로 초기화 시켜준다 (소유권 이전)
		//other.mPtr = nullptr;
	}

	Dog& operator=(const Dog& other)	//copy assignment
	{
		if (&other == this)		//포인터를 이용한 리소스일 경우를 대비해서 만약 같은 객체를 copy하려가 한다면 자신의 객체를 바로 반환한다. 
		{
			return *this;
		}
		mName = other.mName;		//대입 연산자로 other의 이름과 나이를 copy함
		mAge = other.mAge;

		return *this;
	}

	Dog& operator=(Dog&& other) noexcept	//move assignment
	{
		if (&other == this)
		{
			return *this;
		}
		mName = std::move(other.mName);		//대입 연산자로 other의 이름의 소유권을 가져옴
		mAge = other.mAge;	//대입 연산자로 ohter의 나이를 단순히 복사

		return *this;
	}

	void print()
	{
		std::cout << mName << " " << mAge << std::endl;
	}
private:
	std::string mName;
	int mAge;
};

int main()
{
	Dog choco{ "choco", 9 };
	Dog mong{ "mong", 8 };
	
	choco = choco;
	choco = std::move(choco);		//포인터를 이용한 리소스일 경우  문제가 생길 수 있다. 

	mong = std::move(choco);
	mong.print();
	choco.print();
}

 

 

Ref.

https://en.cppreference.com/w/cpp/language/rule_of_three

https://www.youtube.com/watch?v=aSrFrUOeQP0&list=PLDV-cCQnUlIYTBn70Bd822n2nlJwafCQE&index=7 

+ Recent posts