
Access function
class에서 private: 접근 지정자에 있는 멤버 데이터는 클래스 밖에서 직접 접근이 불가능하다
class Knight
{
private:
int hp{ 100 };
};
int main()
{
Knight k{};
k.hp; //error (private이기 때문에 접근 불가)
}
이렇게 private:에 있는 멤버 데이터의 값을 가져오거나 수정하는 함수를 Access function이라고 한다
대표적으로 getter/setter로 나뉜다, 말 그대로 getter는 private:에 있는 멤버 데이터를 가져오는 public 멤버 함수이고 setter는 private:에 있는 멤버 데이터를 수정할 수 있는 public 멤버 함수이다
getter는 accessor, setter는 mutator라고 부르기도 한다
getter는 값을 가져오기만 하고 수정하지 않기 때문에 const로 선언하는게 좋다, setter는 실제로 값을 수정해야 하기 때문에 const를 사용하지 않는다
class Knight
{
private:
int hp{};
public:
int GetHp() const { return hp; }
void SetHp(int InHp) { hp = InHp; }
};
int main()
{
Knight k;
k.GetHp(); //getter 호출
k.SetHp(200); //setter 호출
}
이러한 getter와 setter함수에는 보통 접두사를 많이 사용한다 (get, set), 이러한 접두사 사용으로 해당 함수는 호출 비용이 저렴해야 함을 명시하는 느낌도 있다
ex) GetHp(), SetHp()
getter와 setter의 이름을 동일하게 하고 함수 오버로딩으로 사용 시 구분하는 방법도 있다 (C++ 표준 라이브러리에서 이러한 방식을 사용함)
ex) Hp(), Hp(int InHp)
단 이러한 방식은 호출 시 명확하지 않다는 단점이 있다
getter의 return
getter는 멤버 데이터 복사가 저렴한 경우 값 타입으로 return하면 되고 복사 비용이 큰 경우 const&로 반환하는것이 좋다
사실 이러한 Access function을 만드는것이 좋은 클래스 설계인지 아닌지는 사람마다 생각하는 바가 많이 다른 듯 하다
꼭 '외부에서' 개별 멤버의 데이터를 가져오거나 수정해야하는 경우에만 Access function을 만드는것이 좋다고 생각한다
멤버 데이터를 &로 반환하는 멤버 함수
참조로 return되는 객체는 함수가 종료된 후에도 존재해야 한다 (그렇지 않으면 존재하지 않는 객체를 참조하게 되어 의도치 않은 동작이 발생할 수 있기 때문 (dangling ref))
이 말은 결국 지역변수를 &로 return해서는 안된다는 의미이다 (UE에서는 local var을 &로 return하게 되면 컴파일 에러를 발생시킨다)
참조로 전달된 매개변수나 static 객체는 함수가 종료되어도 소멸되지 않기 때문에 &로 return해도 괜찮다
const std::string GetString(const std::string& InString)
{
return InString; //local var이 아닌 &로 받은 매개변수를 return하였기 때문에 안전함
}
이러한 규칙은 멤버 함수에서도 동일하게 작용한다
또한 멤버 함수가 값 타입을 반환하게 된다면 복사 비용이 많이 들어갈 수 있다, 기본적으로 getter()와 setter()는 많이 호출되기 때문에 최대한 성능상 유리하게 설계할 필요가 있다
대표적인 방법이 바로 참조나 포인터로 반환하는것이다
class Knight
{
private:
std::string name{};
public:
const std::string& GetName() { return name; }
}
Knight K{};
K.GetName(); //복사가 발생하지 않고 Knight의 멤버 데이터인 name의 참조를 return하여 성능상 유리함
여기서 Knight클래스 타입 객체인 K는 GetName()이 끝나도 valid하기 때문에 dangling ref가 발생하지 않는다 (local variable을 참조로 return한 것과 다르게)
멤버 함수가 참조타입으로 반환할 경우 따로 불필요한 변환이 필요 없도록 반드시 타입을 동일하게 맞춰주는게 좋다, 혹은 const auto&를 이용하여 컴파일러가 반환형을 추론하게 하는것도 추가 불필요한 변환을 없애는 방법 중 하나이다
다만 auto는 type이 명확하게 보일때만 사용하는걸 권장한다 ex) cast<>)
(정확히 어떤 타입인지 파악하기가 힘들어진다)
rvalue 객체의 멤버에 대한 참조
rvalue 객체는 생성된 전체 표현식이 끝날 때 소멸하게 된다, 따라서 이러한 rvalue 객체를 참조하여 나중에 사용하게 되면 소멸된 객체를 참조하기 때문에 dangling ref가 발생하고 의도하지 않은 동작이 발생할 수 있다
rvalue 객체 참조는 해당 전체 표현식 라인에서만 사용하는게 좋다
class Knight
{
private:
std::string m_name;
public:
void SetName(const std::string& name)
{
m_name = name;
}
const std::string& GetName() const
{
return m_name;
}
};
Knight CreateKnight(const std::string& name)
{
Knight newKnight;
newKnight.SetName(name);
return newKnight;
}
int main()
{
std::cout << CreateKnight("Arthur").GetName() << '\n'; //rvalue 전체 표현식에서 사용했기 때문에 안전함
const std::string& refString{ CreateKnight("Arthur").GetName() }; //rvalue객체의 멤버 데이터 참조를 cache했다가 쓰려고 하면 위험함 (rvalue객체가 소멸되기 떄문)
std::string valString{ CreateKnight("Arthur").GetName() }; //단순히 값타입 지역변수에 해당 값을 복사해서 쓰는건 안전함
return 0;
}
일반적으로 참조를 반환하는 함수의 return값은 그 즉시 사용하는게 좋다 (dangline ref를 피하기 위함), 따로 cache 후 사용하는건 좋지 않다, 만약 cache하고 사용해야 한다면 값 타입으로 복사해서 저장하고 사용하는걸 권장한다
또한 private: 멤버 데이터를 return하는 함수를 만들때 const가 아닌 참조를 반환하지 않도록 하자, 결국 private: 접근 지정자 아래에 있다는 건 숨기겠다는 의미인데 const가 아닌 참조 반환 함수를 호출자가 받아서 수정할 수 있기 때문이다
(따라서 const 함수는 non-const &을 return할 수 없다)
캡슐화, 데이터 은닉
우선 C++에서 왜 기본적으로 멤버 데이터는 private:으로 지정될까?
가장 중요한 이유는 인터페이스와 구현의 분리를 강제하기 위해서이다, 클래스 인터페이스를 구현하고 실제 클래스 타입의 객체와 상호작용하는 방식으로 사용한다
(사용하는 입장에서 멤버 데이터에 직접적으로 접근할 이유가 없다)
멤버 데이터에 직접 접근하여 사용하게 되면 코드의 안전성과 유지보수성이 떨어지게 된다
C++에서 데이터 은닉은 private:에 선언하여 외부에서 접근하지 못하게 하면 된다
그리고 이러한 데이터를 활용한 동작들을 public: 함수로 만들어 사용하면 된다
하지만 struct는 private:에 선언하게 되면 aggregate(집합체)로 취급할 수 없어 private:에 선언하는걸 권장하지 않는다 (모든 멤버 데이터가 public:이어야 하기 때문)
그렇다면 캡슐화란 무엇일까?
데이터와 해당 데이터를 조작하는 함수를 함께 묶는것을 의미한다
결국 class는 멤버 변수, 멤버 함수를 묶는것이기 때문에 클래스 타입 자체가 캡슐화 되어 있다고 할 수 있다
이렇게 캡슐화 된 class를 사용할 때는 인터페이스만 이해하면 되고 실제 구현 방식은 알 필요가 없다, 어떤 값을 return하고 어떤 값을 input으로 받는지만 알고 사용하면 된다
예를 들면 다음과 같다
int main()
{
std::string_view sv{ "Kelvin" };
sv.length();
}
우리는 이 length()라는 std::string_view 클래스 멤버 함수가 어떻게 구현되었는지 알 필요가 없다, 단순히 문자열의 길이를 return하고 input값이 무엇인가만 알면 된다
이러한 데이터 은닉은 이전에 설명한 클래스 불변식(class invariant)를 유지할 수 있게 한다
class Student
{
std::string m_name{};
char m_firstInitial{};
public:
void setName(std::string_view name)
{
m_name = name;
m_firstInitial = name.front();
}
void print() const
{
std::cout << "Student " << m_name << " has first initial " << m_firstInitial << '\n';
}
};
이렇게 m_firstInitial은 항상 m_name의 맨 앞 문자로 지정하여 클래스 불변식을 유지할 수 있다, 만약 m_name이 public:이라면 외부에서 수정이 가능하기 때문에 m_firstInitial과 일치하지 않아 클래스 불변식이 무너질 수 있다는 의미이다
이렇게 데이터 은닉과 캡슐화를 사용하면 Student class type 객체를 사용할 때 m_name과 m_firstInitial을 각각 따로따로 신경써서 맞출 필요가 없어지고 setName()으로 한번에 처리가 가능해진다
또한 더 나은 오류 감지 및 처리가 가능해진다
예를들어 위에서 m_name이 public:이라면 이름을 공백으로 수정할 수 있게 되고 m_firstInitial은 결국 가져올 수 없게 된다, 이때 위와 같이 캡슐화 후 해당 함수 내부에서 공백 입력을 막아준다면 더 나은 오류 감지 및 처리가 가능해진다
(void SetName()에서 들어온 문자열이 공백이 절대 안되게 추가 코드 처리)
다음으로 데이터 은닉과 캡슐화를 이용하여 기존의 프로그램을 크래쉬내지 않고 구현 사항을 변경할 수 있다
struct Something
{
int value1 {};
int value2 {};
int value3 {};
};
int main()
{
Something something;
something.value1 = 5;
std::cout << something.value1 << '\n';
}
위와 같은 코드에서 value1,2,3를 value라는 이름의 배열로 변경하게 되면 다음과 같은 코드는 작동하지 않게 된다
struct Something
{
int value[3] {}; // 3개의 값을 가진 배열 사용
};
int main()
{
Something something;
something.value1 = 5; //value1이 없음!
std::cout << something.value1 << '\n';
}
이럴때도 데이터 은닉과 캡슐화를 사용하여 구현이 변경되어도 기존 프로그램의 크래쉬를 발생시키지 않을 수 있다
class Something
{
private:
int m_value1 {};
int m_value2 {};
int m_value3 {};
public:
void setValue1(int value) { m_value1 = value; }
int getValue1() const { return m_value1; }
};
int main()
{
Something something;
something.setValue1(5);
std::cout << something.getValue1() << '\n';
}
class Something
{
private:
int m_value[3]; // 클래스의 구현 변경
public:
// 새 구현을 반영하도록 멤버 함수 업데이트
void setValue1(int value) { m_value[0] = value; }
int getValue1() const { return m_value[0]; }
};
int main()
{
// 하지만 클래스를 사용하는 프로그램은 업데이트할 필요 없음
Something something;
something.setValue1(5);
std::cout << something.getValue1() << '\n';
}
마지막으로 데이터 은닉과 캡슐화는 디버깅에도 효과적이다, 만약 멤버 데이터를 어디서든 변경할 수 있다면 해당 값이 어디서 변경되어서 동작이 이상한지 파악하기가 힘들다
만약 데이터 은닉과 캡슐화를 했다면 해당 함수에서 디버깅을 진행하면 쉽게 원인을 파악할 수 있다
멤버함수, 비멤버함수
만약 비멤버 함수로 구현될 수 있다면 굳이 클래스의 멤버 함수로 구현하지 않는것을 권장한다고 한다
클래스 인터페이스가 더 작고 간단해져 이해하기 쉬워진다
만약 비멤버 함수에서 클래스 멤버 데이터를 사용해야 한다면 비멤버 함수의 인자로 클래스 타입의 객체를 받고 public: 인터페이스를 통해 작동해야 하므로 캡슐화가 강제된다
비멤버 함수로 구현하게 되면 클래스 구현을 변경할 때 고려할 부분이 적어진다
또한 비멤버 함수가 디버깅이 더 쉽다
이러한 비멤버 함수를 권장하는 방식은 같은 OOP 프로그래밍 언어인 C#과 JAVA와는 많이 다르다, C#과 JAVA는 class를 중심으로 모든것이 class를 통해 동작하는 다른 개념적 모델을 사용하기 때문이다 (그래서 멤버 함수를 최우선으로 둔다)
따라서 가능하다면 함수를 비멤버 함수로 구현하는걸 권장한다
단 비멤버 함수를 사용하기 위해 여러개의 getter/setter를 만들어야 해서 클래스 인터페이스의 크기와 복잡성이 증가한다면 고려해봐야 한다
(How Non-Member Functions Improve Encapsulation 참고)
class Knight
{
private:
int hp{ 100 };
public:
int GetHp() { return hp; }
};
//클래스 인터페이스의 일부가 아닌 비멤버함수로 구현
void PrintKnightHp(const Knight& InKnight)
{
std::cout << InKnight.GetHp() << '\n';
}
int main()
{
Knight k{};
PrintKnightHp(k);
}
보통 클래스 멤버 데이터를 선언할 때 public:데이터를 먼저 나열하고 private:데이터를 맨 아래에 선언하는 방식을 권장한다 (순서)
다음은 Google C++ Style Guide 문서에서 나열한 클래스 멤버 데이터 선언 순서이다
