프로그래밍 / C++ / 언리얼

Programming/C | C++ | Unreal

[C++] 객체지향 심화.

아트성 2022. 6. 2. 10:13

클래스의 구성요소   

 

클래스란?

   자료저장 + 자료처리 = 변수 + 함수
   특정한 용도를 수행하기 위한 변수와 함 수를 모아 둔 틀(설계도)

 

 

객체란?

   오브젝트라고 불리우며, 그 틀(설계도)를 이용하여 찍어낸 개체(변수, 메모리 상의 공간)

 

 

사용자 정의 타입

   데이터 멤버

      데이터를 저장한다. 필드(Field)라고도 한다.

 

   멤버 함수

      타입의 기능 부분이다.   - 메소드(Method)라고도 한다.

 

   내부 타입

      클래스 안에 다른 클래스를 만들거나, 열거형, 혹은 타입 별칭을 지정할 수 있다.

 

   멤버 템플릿 

      템플릿을 작성할 수도 있다. 

 

 

클래스 범위

클래스 범위는 클래스의 데이터 및 멤버함수가 이 규칙을 가진다. 멤버 함수를 정의할때
일반한수와 달리 클래스 범위 연산자 ::를 사용해야 한다.

그 이외에 지역범위, 파일범위, 함수범위, 매개변수 범위가 있다.

 

// class 자리에는 struct, class, union 키워드를 작성할 수 있다.
class A
{
    // 클래스 내부에서 선언된 식별자는 클래스 범위(Class Scope)를 가진다.
    int  _data; // 데이터 멤버
 
    void foo(); // 멤버 함수
 
    // 내부 클래스(Nested Class)
    class B
    {
      int _data;
    };
 
    // 열거형
    enum { TEMP1, TEMP2, TEMP3 };
 
    // 타입 재정의
    typedef B MyNestedClass;
    using MyNestedClass = B; // 바로 위 구문과 동일하다.
};
 
// 클래스 타입의 변수는 기본 타입과 똑같이 정의할 수 있다.
// a를 A에 대한 인스턴스(Instance)라고 한다.
A a;



접근 지정자

   public

      외부에서 접근 가능 


   private

      외부에서 접근 불가능 


   protected

      자식클래스에서 접근 가능 / 외부에서 접근 불가능

 

 

this 포인터

this포인터란? 객체의 주소를 가리키는 포인터이며, 멤버함수의 보이지않는 매개변수이다.

즉, 인스턴스 메소드의 첫 번째 매개변수는 클래스 타입의 포인터라고도 할 수 있다.

Add(int value, double value2) // Hidden State

Add(A* const this, int value, double value2) // None Hidden State

 

비 정적 멤버 / 정적 멤버

비정적 멤버는 인스턴스가 사용하는 멤버이며, 정적 멤버는 인스턴스와 관계없이 사용하는 멤버다.

비정적 멤버의 특징은 this포인터라는 것으로 인스턴스의 데이터를 접근 할 수 있다.

 

정적멤버는 비정적멤버와 달리, static을 사용한다. 이렇게 되면, 외부에서 클래스 범위 연산자를 이용해서 호출할 수 있다. 정적멤버는 주소공간중 스택이나 힙이 아닌 데이터 영역을 사용한다.

#include <iostream>

using namespace std;

class Color
{
public:
Color() : r(0), g(0), b(0) {}
Color(float r, float g, float b) : r(r), g(g), b(b) {}

float GetR() { return r; }
float GetG() { return g; }
float GetB() { return b; }

static Color MixColors(Color a, Color b)
{
return Color((a.r + b.r) / 2, (a.g + b.g) / 2, (a.b + b.b) / 2);
}


private:
float r;
float g;
float b;
};



int main()
{
Color blue(0, 0, 1);
Color red(1, 0, 0);

Color purple = Color::MixColors(blue, red);

cout << purple.GetR() << " " << purple.GetG() << " " << purple.GetB() << endl;
}

 

 

클래스의 기본 메소드

 

   기본 생성자 (Default Constructor)

        인스턴스의 초기화를 담당하고, 매개변수가 없는 생성자를 기본 생성자라고 한다.

        생성자는 또한 필요한만큼 오버로딩이 가능하다.

        기본 생성자는 클래스 안에 아무런 생성자도 적어주지 않을시 자동으로 합성된다.

 

 

   소멸자 (Destructor) 

       소멸자는 객체의 수명이 다할 때 자동으로 호출되는 특수한 메소드로 자원을 정리하기 위해 사용한다.

 

 

   복사 생성자 (Copy Constructor)

       생성자 중 동일 타입의 레퍼런스를 인자로 받는 생성자

 

 

   복사할당연산자 (Copy Assignment Operator)

       매개변수의 타입이 클래스 타입과 동일한 할당 연산자를 말한다.

 

 

 

상속   

 

객체지향 4대 개념

캡슐화 클래스(Class)를 통해 변수와 함수를 하나의 단위로 묶는 것을 의미한다. 

특징 : 이 클래스를 선언하고, 해당 클래스의 인스턴스를 통해 클래스 안에  포함된 멤버 변수와 메소드에 쉽게 접근할 수 있다.
상속 코드를 물려받는 것 (부모클래스 -> 자식클래스)

classProfessor : publicPerson( C++ 코딩 스타일 // 물려받는 쪽 : 물려 주는 쪽 )
특징 : 코드 재사용. 객체지향에서는 상속을 이용하여 중복을 줄일 수 있음.
추상화 구현 세부 정보를 숨기는 일반적인 인터페이스를 정의하는 행위’

인터페이스 : 서로 다른 부분이 만나고 소통하거나 서로에게 영향을 미치는 영역 (휴대폰 - 자신의 전화번호, 배터리 잔량같은것들을 변수로 추상화가 가능하다.)
다형성 하나의 객체가 여러가지 형태(타입)을 가지는 것을 의미한다.

부모클래스에서 가진 함수를 자식 클래스에도 사용 할 수 있다.
가상함수(Virtual Function) 를 사용하게 되면 부모 클래스에서 선언한 함수가 자식 클래스에서 재정의 될 수 있다고 알려주게 된다.
가상 함수는 인터페이스를 유지한 채로 구현 세부 사항을 바꿀 수 있다. 

상속 기호

정의할 때, 뒤에 콜론( : )을 붙이고 상속할 클래스를 적어주면 된다. 여기서 상속할 클래스를 적을 때 접근지정자를 함께 적어줄수 있는데 주로 public만 쓰인다.

 

생성 / 소멸 순서

   1. 부모클래스 생성

   2. 자식클래스 생성

   3. 자식클래서 소멸

   4. 부모클래스 소멸

 

가상 함수 (virtual Function)

  다형성을 지원하기 위한 기능이다. 가상함수를 작성하려면 virtual 한정자를 적는다.

  가상함수를 작성하면 무조건 다형성을 사용 할 수있는것은 아니다.

  바로 부모클래스 타입을 가리키는 포인터나 래퍼런스로 업캐스팅(upcasting)하여 다뤄야 한다. (이와 반대는 다운캐스팅)

class Base
{
public:
    virtual void Foo()
    {
        std::cout << "Base::Foo()\n";
    }
};
 
class Derived : public Base
{
public:
    // 가상 함수 재정의는 그냥 정말 재정의를 해주면 된다.
    void Foo()
    {
        std::cout << "Derived::Foo()\n";
    }
};
 
Base b;
Derived d;
 
Base& b1 = d; // 업캐스팅
b1.Foo(); // "Derived::Foo()"

 

오버라이딩 (Overriding)

 - 가상 함수의 내용을 재정의하는 것을 오버라이딩이라고 한다.

 - 오버로딩과 오버라이딩 차이

   * 오버로딩 : 같은 이름의 함수에 매개변수를 다르게 사용하여 매개변수에 따라 다른함수가 실행되는 것.

   * 오버라이딩 : 부모클래스로부터 상속받은 함수를 자식클래스에서 같은 이름의 함수에 매개변수를 같게 재정의해서 사용하는것.

 

동적 바인딩 (Dynamic Binding)

 - 가상 함수는 실행시간에 어떤 함수를 호출할 것인지 결정하는데 이를 동적 바인딩이라고 한다. 

 - 정적바인딩(Static Binding)은 동적바인딩과는 반대로 어떤함수를 호출할지 이미 결정된 것을 말한다.

 

 

가상함수 테이블

 - 동적 바인딩을 위해 가상함수의 주소가 저장되어있는 배열(vftable이며 virtual지정자 수만큼 할당된다. )

 - 부모클래스에서 만든 테이블은 자식클래스에 그대로 복사가 되는데,  자식클래스에서 새로운 가상함수를 추가할 경우 vftable의 마지막 부분에 추가가 된다.

 

 

가상함수 포인터

 - 인스턴스가 생성될 때 초기화 되며, 초기값은 각 타입의 가상함수 테이블이 된다. 

#include <iostream>
 
using namespace std;
 
struct Base
{
  // Base 타입의 가상 함수 테이블은 아래와 같이 구성된다.
  // vftable[0] = Base::Foo
  // vftable[1] = Base::Boo
  virtual void Foo() { cout << "Base Foo\n"; }
  virtual void Boo() { cout << "Base Boo\n"; }
 
  // Coo는 가상 함수가 아니기 때문에
  // 가상 함수 테이블에 들어가지 않는다.
  void Coo() { cout << "Base Coo\n"; }
 
// 가상 함수가 존재하기 때문에 아래와 같이
// 모든 인스턴스는 가상 함수 포인터를 갖게 된다.
// private: void** __vfptr = Base::vftable;
 
// 가상 함수 포인터는 인스턴스가 생성될 때 초기화 되며
// 초기값은 각 타입의 가상 함수 테이블이 된다.
};
 
struct Derived : Base
{
  // Derived의 가상 함수 테이블은 아래와 같이 구성된다.
  // vftable[0] = Derived::Foo
  // vftable[1] = Base::Boo
  // vftable[2] = Derived::Aoo
  void Foo() { cout << "Derived Foo\n"; }
  virtual void Aoo() { cout << "Derived Aoo\n"; }
 
  void Coo() { cout << "Derived Coo\n"; }
};
 
Base b;
Base* p = &b;
p->Foo(); // Base Foo
p->Boo(); // Base Boo
p->Coo(); // Base Coo
 
Derived d;
p = &d;
p->Foo(); // Derived Foo
p->Boo(); // Base Boo
 
// 가리키고 있는 타입이 Base이므로 Base::Coo가 호출된다.
p->Coo(); // Base Coo.

// 다운캐스팅을 하여 Derived::Coo가 호출된다.
((Derived*)p)->Coo(); // Derived Coo

 

 

동적할당

객체의 할당은 크게 정적할당과 동적할당으로 나뉜다. 인스턴스를 생성하고 생성자를 호출할때 어떤 메모리 영역에는 할당작업이 일어난다. 정적할당은 데이터 영역에 저장되고 동적할당은  영역에 저장이 되는데, 객체지향 프로그래밍에서 가장 많이 쓰이는 방식은 동적할당이다. 

 

new와 delete라는 연산자를 이용해서 동적할당이 일어나는데 new를 쓰는 즉시 인스턴스의 생성자를, delete를 쓰면 소멸자를 호출한다. 

 

 

객체의 탄생 / 소멸

#include <iostream>

using namespace std;

class Vector2
{
public:
	Vector2() : x(0), y(0)
	{
		cout << this << " : Vector2()" << endl;
	}

	Vector2(const float x, const float y) : x(x), y(y)
	{
		cout << this << " : Vector2(const float x, const float y)" << endl;
	}
	~Vector2()
	{
		cout << this << " : ~Vector2()" << endl;
	}

	float GetX() const { return x; }
	float GetY() const { return y; }

private:
	float x;
	float y;
};

int main()
{
	Vector2 s1 = Vector2(); // 정적 할당 -> 데이터 영역에 저장
	Vector2 s2 = Vector2(3, 2); // 정적 할당 -> 데이터 영역에 저장

	Vector2* d1 = new Vector2(); // 동적 할당 -> 힙 영역에 저장
	Vector2* d2 = new Vector2(3, 2); // 동적 할당 -> 힙 영역에 저장

	cout << "(" << d1->GetX() << ", " << d1->GetY() << ")" << endl;
	cout << "(" << d2->GetX() << ", " << d2->GetY() << ")" << endl;

	delete d1; // 메모리 해제
	delete d2; // 메모리 해제
}

위 코드의 붉은색 네모박스를 살펴보면 동적할당되는 부분은 new와 delete순서에 따라 프로그램 실행시간에 할당과 해제가 일어난 것을 확인 할 수 있다.

 

반면에 네모박스를 제외한 부분인 정적할당이 일어난 부분에는 프로그램 실행전에 생성과 해제순서가 결정되었기때문에 순차적으로 생성과 해제가 일어난 것을 확인할 수 있다.

 

 

 

가상 소멸자

보통 객체지향에선 업캐스팅하여 객체를 다루게 되는데, 이 경우 객체의 소멸이 제대로 안되는 것을 확인할 수 있다. 왜냐하면 결국 상위 타입으로 다루고 있기 때문이다. 따라서 올바르게 소멸자를 호출하고 싶다면 가상소멸자(Virtual Destructor)를 정의해야 한다.

 

class Base
{
  Base() { std::cout << "Constructor"; }
  ~Base() { std::cout << "Destructor"; }
};
 
class Derived : Base
{
  Derived() { std::cout << "Constructor"; }
  ~Derived() { std::cout << "Destructor"; }
};

Base* b = new Derived();
delete b; // ~Base()만 호출됨.
class Base
{
  Base() { std::cout << "Constructor"; }
  virtual ~Base() { std::cout << "Destructor"; }
};

class Derived : Base
{
  Derived() { std::cout << "Constructor"; }
  ~Derived() { std::cout << "Destructor"; }
};
 
Base* b = new Derived();
delete b; // 이제는 올바르게 소멸된다.

 

 

 

 

 

 

 

추상 클래스   

인스턴스를 만들 수 없는 클래스이다. 추상클래스 내부에는 순수가상함수가 들어있으며, 순수가상함수의 역할은 하위타입으로 하여금 오버라이딩을 강제하는 역할을 한다.

Class A
{
	virtual void Foo() = 0; // 순수 가상함수.
};

A a; // ERROR!!, 추상 타입은 인스턴스를 생성할 수 없다.

 

 

 

 

연산자 오버로딩   

복사할당연산자를 이용해서 클래스 타입을 매개변수로 받아서 연산을 이용할 수 있다.

Vector2의 예제를 가져와보았다. 우리가 흔히 벡터연산할때 벡터의 내적과 외적을 고려한 연산이 들어가는데 아래 예제 보면 외적은 반환타입이 float 인것만 주의해주면 된다.

#include <iostream>

using namespace std;

class Vector2
{
    public: 
    Vector2();
    Vector2(float x, float y);

    float GetX() const;
    float GetY() const;

    Vector2 operator+(const Vector2& rhs) const;
    Vector2 operator-(const Vector2& rhs) const;
    Vector2 operator*(const float rhs) const;
    Vector2 operator/(const float rhs) const;
    float operator*(const Vector2& rhs) const;

    private:
    float x;
    float y;

};

// 생성자 초기화
Vector2::Vector2() : x(0), y(0) { }

Vector2::Vector2(float x, float y) : x(x), y(y) {}


// x값 반환
float Vector2::GetX() const { return x; }


// y값 반환
float Vector2::GetY() const { return y; }


// 연산자 오버로딩
Vector2 Vector2::operator+(const Vector2& rhs) const
{
    return Vector2(x + rhs.x, y + rhs.y);
}

Vector2 Vector2::operator-(const Vector2& rhs) const
{
    return Vector2(x - rhs.x, y - rhs.y);
}

Vector2 Vector2::operator*(const float rhs) const
{
    return Vector2(x * rhs, y * rhs);
}

Vector2 Vector2::operator/(const float rhs) const
{
    return Vector2(x / rhs, y / rhs);
}

float Vector2::operator*(const Vector2& rhs) const
{
    return x * rhs.x + y * rhs.y;
}

int main()
{
    Vector2 a(2, 3);
    Vector2 b(-1, 4);
    Vector2 c = a + b;
    Vector2 c1 = a - b;
    Vector2 c2 = a * 1.6;
    Vector2 c3 = a / 2;
    float c4 = a * b;

    cout << c.GetX() << ", " << c.GetY() << endl; // 1, 7
    cout << c1.GetX() << ", " << c1.GetY() << endl; // 3, -1
    cout << c2.GetX() << ", " << c2.GetY() << endl; // 3.2, 4.8
    cout << c3.GetX() << ", " << c3.GetY() << endl; // 1, 1.5
    cout << c4 << endl; // 10
}

 

 

 

상수형 메서드 / 매개변수   

const 위치에 따라서 메서드를 상수화 시킬수있고, 매개변수 자체를 상수화 시킬 수 있다. 상수화를 시키는 이유는 클래스 내부에서 데이터를 처리할 때 미연의 실수를 방지하기 위해서다.

 

메서드의 상수화 (멤버 메서드)

함수범위 내에서 멤버변수의 수정이 불가능 하다, 단 읽기는 가능.

 

int ViewHealth() const
{
	return Health;
}

 

 

매서드의 리턴타입의 상수화

외부에서 함수의 리턴 값 수정 불가능(거의 쓰이는 경우가 없음)

const int ViewState()
{
	return Health;
}

 

 

매개변수의 상수화

들어오는 인자값의 수정이 불가능하다.

void Heal(const int healValue)
{
	// healValue = 100; // ERROR!!
	Health += healValue;
	cout << "몬스터가 " << healValue << "만큼 회복 했습니다." << endl;
}

void Attack(const int damage)
{
	// damage = 50; // ERROR!!
	if (Health >= damage)
	{
		Health -= damage;
		cout << "몬스터가 " << damage << "만큼 공격 받았습니다." << endl;
	}
}
#include <iostream>

using namespace std;

class Monster
{
    public:
    Monster() : Health(0) {}
    Monster(int _helath) : Health(_helath) {}

void Heal(const int healValue)
{
    // healValue = 100; // ERROR!!
    Health += healValue;
    cout << "몬스터가 " << healValue << "만큼 회복 했습니다." << endl;
}

void Attack(const int damage)
{
    // damage = 50; // ERROR!!
    if (Health >= damage)
    {
        Health -= damage;
        cout << "몬스터가 " << damage << "만큼 공격 받았습니다." << endl;
	}
}

int ViewHealth() const
{
    // Helath = 80; // ERROR!!
    return Health;
}

const int ViewState()
{
    return Health;
}

private:
	int Health;
};

int main()
{
    Monster monster(100);
    monster.Heal(100);
    monster.Attack(20);
    //monster.ViewState() += 50;// ERROR!!

    cout << "몬스터의 현재 체력 : " << monster.ViewHealth() << endl;
}

 

 

 

 

SOLID   

SOLID 원칙은 클린 아키텍쳐에서 에서 소개된 객체지향 설계의 바탕이 되는 5가지 원칙을 말한다. 

 

 

단일 책임 원칙 ( SRP )

객체는 한가지의 기능만을 가지며 관련없는 기능은 합치지 않는다.

기존 코드를 변경하지 않으면서 기능을 추가할 수 있도록 설계가 되어야 한다는 원칙으로, 각 소프트웨어 모듈은 변경의 이유가 하나여야한다.

 

어떤 객체가 오디오의 기능도 수행하고 있고, 이미지를 보여주는 기능도하면 변경의 이유가 두가지가 된다.  이런 설계를 피할 필요가 있다. 이런 설계가 되어있어야 유지보수하기가 편하다.

 

 

 

 

 

개방-폐쇄 법칙 ( OCP )

 

확장은 개방적, 수정은 폐쇄적

객체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 원칙으로 기존 코드를 수정하기보다는 반드시 새로운 코드를 추가하는 방식으로 시스템의 행위를 변경할 수 있도록 설계해야만 소프트웨어 시스템을 쉽게 변경할 수 있다는 것이다. 캡슐화와 관련이 깊다.

 

 

 

 

 

 

 

리스코프 치환 원칙 ( LSP )

 

어떤 인스턴스의 자리를 서브타입 인스턴스로 친환할 수 있어야 한다. 상속과 밀접한 관계가 있고, 상속을 할때에는 is A관계가 되어야 한다.

 

   class SeungiL : public Person { ];
   class Bird : public Person { };
   Seungil is a person (o)
   Bird is a Person (x)

 

 

 

 

 

 

 

인터페이스 분리 원칙 ( ISP )

한가지의 인터페이스 보다는 세부적인 여러개의 인터페이스로 분리. 쉽게말해 필요한 인터페이스만을 분리해 의존하게 설계하라는 의미이다.  인터페이스는 깔끔하고 간결해야 한다. 범용 인터페이스보다는 작지만 한 가지의무라도 잘 정의된 인터페이스를 여러개로 구성하는게 낫다.

왼쪽 예시는 플레이어라는 객체가 직업 클래스의 멤버를 호출하는 과정인데, 여러 객체가 직업이라는 클래스에 한 번에 의존하고 있다. 만약 이 상태에서 직업이라는 객체에 수정이 발생하면, 종속되어 있었던 여러 객체들까지 영향을 받게 된다.

 

따라서 오른쪽 그림과 같이 인터페이스를 직업 클래스의 하위 클래스로 두어서 객체가 의존하고 있는 인터페이스만 수정하게 만들면 유지보수가 훨씬 편해질 것이다.

 

 

 

 

 

 

 

의존성 역전 원칙( DIP )

저수준 모듈을 고수준 모듈로 의존 하게만들어 코드의 유지보수를 간편하게 만든다는 원칙이다.

고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 의존해서는 안된다. 세부사항이 정책에 의존해야 하는데, 다시 말하자면 다형성을 적극적으로 활용하라는 얘기다.

 그림1. 의존성 역전 원칙 위반                                                                                                        그림2. 의존성 역전 원칙의 올바른 예

 

왼쪽 예시처럼 설계를 하게되면 문제점이 하나 생기는데 만약에 몬스터라는 객체가 몸통박치기가 아닌 "독뿌리기"나 "불꽃펀치"같은 스킬을 추가로 사용하게 되었을 경우에 수정이 어려워질수 있다. 반면 오른쪽 관계도와 같이 공격 스킬이라는 추상화된 클래스를 만들면 유지보수가 쉬워진다.

 

따라서 오른쪽과 같이 저수준 모듈 추상화된 클래스(공격스킬)에 의존하게 만들면 고수준 모듈 저수준모듈에 직접적인 의존성을 가지지 않는것이 가능하게 된다. 

 

따라서 몬스터라는 객체가 여러가지 스킬을 구현하려면 공격스킬이라는 추상 클래스에 "순수가상함수"를 만들어 자식클래스에 상속시켜주고  몬스터 객체는 특정스킬을 공겨스킬 클래스타입으로 업캐스팅을 시켜서 공격스킬타입을 매개변수로 받으면 정삭적으로 스킬구현이 가능하게 된다.

 

 

 

반응형