LearnCPP - 15

Justin·2026년 3월 1일

LearnCPP.com

목록 보기
16/22

15.1 — 숨겨진 “this” 포인터와 멤버 함수 체이닝

새내기 프로그래머들이 클래스에 대해 자주 묻는 질문 중 하나는 바로 이것입니다.

"멤버 함수를 실행할 때, C++은 대체 어떤 객체에서 그 함수를 실행해야 하는지 어떻게 기억하고 있는 걸까요?"

먼저 이 상황을 확인해 보기 위해 아주 간단한 클래스를 하나 만들어 볼게요.
이 클래스는 숫자(정수) 하나를 품고 있고, 그 숫자를 가져오거나 바꾸는 간단한 함수들을 가지고 있습니다.

#include <iostream>

class Simple
{
private:
    int m_id{};

public:
    Simple(int id)
        : m_id{ id }
    {
    }

    int getID() const { return m_id; }
    void setID(int id) { m_id = id; }

    void print() const { std::cout << m_id; }
};

int main()
{
    Simple simple{1};
    simple.setID(2);

    simple.print();

    return 0;
}

여러분이 예상하신 대로, 이 프로그램은 다음 결과를 보여줍니다.

2

여기서 신기한 점이 있어요. 우리가 simple.setID(2); 를 불렀을 때, C++은 setID() 라는 함수가 simple 이라는 특정 객체를 위해 일해야 한다는 것을 알고 있습니다.
게다가 코드 안에 있는 m_id 가 사실은 simple.m_id 를 가리킨다는 것도 귀신같이 알아채죠.

그 비밀은 바로, C++이 뒤에서 몰래 this 라는 이름의 '숨겨진 포인터'를 사용하고 있기 때문입니다! 이번 레슨에서는 이 this 에 대해 더 자세히 파헤쳐 볼게요.


숨겨진 this 포인터

모든 멤버 함수 안에는 this 라는 키워드가 숨어 있습니다. 이건 '현재 작업 중인 바로 그 객체'의 주소가 적힌 지워지지 않는 명찰(const pointer)이라고 생각하시면 돼요.

보통 우리는 this 를 굳이 눈에 보이게 쓰지 않지만, 원한다면 직접 꺼내서 쓸 수도 있다는 걸 보여드릴게요.

#include <iostream>

class Simple
{
private:
    int m_id{};

public:
    Simple(int id)
        : m_id{ id }
    {
    }

    int getID() const { return m_id; }
    void setID(int id) { m_id = id; }

    void print() const { std::cout << this->m_id; } // `this` 포인터를 사용해 현재 객체를 가리키고, -> 기호로 m_id 변수를 콕 집어냅니다
};

int main()
{
    Simple simple{ 1 };
    simple.setID(2);

    simple.print();

    return 0;
}

이 코드도 방금 전 예제와 완전히 똑같이 작동하고, 2를 출력합니다.
여기서 이전 코드와 이번 코드의 print() 함수가 사실상 100% 똑같은 일을 한다는 걸 주목해 주세요.

void print() const { std::cout << m_id; }       // 내가 안 써도 C++이 알아서 this를 써줌 (암시적)
void print() const { std::cout << this->m_id; } // 내가 직접 this를 써줌 (명시적)

알고 보니 첫 번째 코드는 두 번째 코드를 쓰기 편하게 줄여 놓은 거였어요! 우리가 짠 코드를 컴퓨터가 알아들을 수 있게 번역(컴파일)할 때, 컴파일러는 객체의 멤버를 사용할 때마다 몰래 앞에 this-> 를 붙여줍니다. 덕분에 우리가 매번 귀찮게 this-> 를 반복해서 쓰지 않아도 되니까 코드가 훨씬 깔끔해지는 거죠.

잠깐 복습!
포인터(주소표)를 통해 객체 안의 내용물을 꺼낼 때는 -> 라는 화살표 기호를 씁니다. this->m_id(*this).m_id 와 완전히 똑같은 뜻이에요.


this 는 어떻게 몰래 전달될까요?

아래의 함수 호출 코드를 돋보기로 들여다볼까요?
simple.setID(2);

setID(2) 함수를 부를 때 괄호 안에 재료(인자)가 '2' 하나만 있는 것 같죠? 하지만 사실은 두 개랍니다! 컴파일러가 코드를 번역할 때, 이 줄을 이렇게 슬쩍 바꿔버립니다.

Simple::setID(&simple, 2); // 주목: 주인공이었던 simple 객체가 함수 괄호 안의 첫 번째 재료로 들어갔어요!

이제 이건 아주 평범한 함수 모양이 되었습니다. 그리고 함수 이름 앞에 있던 simple 객체는, 자신의 주소(&simple) 형태로 함수의 첫 번째 재료가 되어 전달되죠.

하지만 이건 절반의 설명일 뿐이에요. 함수를 부르는 쪽에서 재료를 하나 더 던져주기로 했으니, 함수를 정의하는 쪽에서도 그 재료를 받을 바구니(매개변수)를 하나 더 만들어야겠죠? 원래 우리의 setID() 함수는 이랬습니다.

void setID(int id) { m_id = id; }

이 코드가 최종적으로 어떻게 변하는지는 컴퓨터 환경마다 조금 다를 수 있지만, 대략 이런 모습으로 바뀝니다.

static void setID(Simple* const this, int id) { this->m_id = id; }

setId 함수 괄호 안 맨 왼쪽에 this 라는 새로운 바구니(매개변수)가 생겼네요! 이건 다른 곳을 가리키도록 바꿀 수는 없는 고정된 포인터(const pointer)입니다. 그리고 안쪽에 있던 m_id 도 방금 받은 this 를 사용해서 this->m_id 로 바뀌었죠.

(고급 독자를 위한 참고: 여기서 static 이란 뜻은, 이 함수가 특정 객체에 찰싹 달라붙어 있는 게 아니라, 클래스라는 동네 안에 있는 평범한 함수처럼 취급된다는 뜻입니다.)

총정리 해볼까요?

  1. 우리가 simple.setID(2) 를 실행하면, 컴퓨터는 몰래 Simple::setID(&simple, 2) 를 실행해서 simple 의 주소표를 함수에 넘겨줍니다.
  2. 이 함수 안에는 this 라는 숨겨진 바구니가 있어서, 방금 날아온 simple 의 주소표를 쏙 받아냅니다.
  3. 함수 안에서 쓰인 멤버 변수 앞에는 컴파일러가 알아서 this-> 를 붙여줍니다. thissimple 을 가리키고 있으니, this->m_id 는 결국 simple.m_id 를 뜻하게 되는 거죠.

다행인 점은 이 모든 복잡한 과정이 100% 자동으로 일어난다는 것입니다. 작동 원리를 다 못 외우셔도 프로그래밍하는 데 아무 지장 없어요! 딱 한 가지만 기억하세요.

핵심 포인트
모든 일반 멤버 함수에는 현재 작업 중인 객체를 가리키는 this 라는 숨겨진 안내판(포인터)이 있습니다. this 는 항상 '지금 일하고 있는 바로 그 녀석'을 가리킵니다.


this 가 직접 나설 때 (명시적으로 쓰기)

대부분의 경우 여러분이 직접 this 를 타자 칠 일은 없습니다.
하지만 가끔 직접 써주면 아주 유용할 때가 있어요.

첫 번째로, 클래스의 변수 이름과 함수의 매개변수(들어오는 재료) 이름이 완전히 똑같을 때, this 를 써서 "누가 누군지" 확실히 구분해 줄 수 있습니다.

struct Something
{
    int data{}; // 이건 구조체(struct)라서 이름 앞에 m_ 을 안 붙였어요

    void setData(int data)
    {
        this->data = data; // this->data는 내 몸통 안에 있는 멤버 변수이고, 그냥 data는 밖에서 괄호 타고 들어온 재료입니다
    }
};

Something 안에는 data 라는 변수가 있어요. 그런데 setData() 함수가 받는 재료의 이름도 data 네요. 함수 안에서 그냥 data 라고 부르면, 컴퓨터는 내 몸통 안의 변수가 아니라 밖에서 들어온 재료 data 를 뜻하는 걸로 알아듣습니다.

그래서 "아니, 내 몸통 안에 있는 원래 내 data 말이야!" 라고 콕 집어 말해주기 위해 this->data 라고 쓰는 거죠. (물론 제일 좋은 방법은 처음부터 헷갈리지 않게 멤버 변수 이름 앞에 m_ 을 붙이는 거랍니다!)


*this 반환하기

두 번째로, 멤버 함수가 자기 자신(현재 객체)을 통째로 뱉어내게(반환하게) 만들면 아주 멋진 일이 벌어집니다. 이렇게 하면 여러 개의 함수를 한 줄에 기차처럼 칙칙폭폭 이어서 쓸 수 있거든요! 이걸 바로 함수 체이닝(Function chaining) 또는 메서드 체이닝(Method chaining) 이라고 부릅니다.

우리가 화면에 글자를 찍을 때 쓰는 std::cout 을 볼까요?
std::cout << "Hello, " << userName;

컴퓨터는 이걸 이렇게 괄호 쳐서 먼저 계산합니다.
(std::cout << "Hello, ") << userName;

먼저 앞부분이 실행돼서 화면에 "Hello, "를 찍어요. 그런데 이 줄이 여기서 끝이 아니라 뒤에 계속 이어져야 하잖아요? 만약 앞부분을 실행하고 남는 결과가 아무것도 없다면(void), 엉뚱하게 이런 모양이 돼버릴 겁니다.

void{} << userName; // 아무것도 없는 빈 공간에 userName을 밀어 넣으라니? (에러 발생!)

그래서 operator<< (화면에 글자 찍는 기능)는 자기 할 일을 다 하고 나면, 자기가 썼던 std::cout 객체를 다시 바깥으로 퉤! 하고 뱉어냅니다(반환합니다). 그럼 코드가 이렇게 바뀌죠.

(std::cout) << userName;

덕분에 우리는 std::cout 을 맨 처음에 딱 한 번만 쓰고도 << 기호를 이용해 원하는 만큼 글자를 계속 이어 붙일 수 있는 거예요.

이 마법 같은 기차놀이를 우리가 만든 클래스에도 똑같이 적용할 수 있습니다! 아래의 계산기 코드를 보세요.

class Calc
{
private:
    int m_value{};

public:
    void add(int value) { m_value += value; }
    void sub(int value) { m_value -= value; }
    void mult(int value) { m_value *= value; }

    int getValue() const { return m_value; }
};

여기서 5를 더하고, 3을 빼고, 4를 곱하고 싶다면 보통은 이렇게 답답하게 세 줄로 나눠서 적어야 합니다.

#include <iostream>

int main()
{
    Calc calc{};
    calc.add(5); // 끝나고 아무것도 안 뱉음 (void)
    calc.sub(3); // 끝나고 아무것도 안 뱉음 (void)
    calc.mult(4); // 끝나고 아무것도 안 뱉음 (void)

    std::cout << calc.getValue() << '\n';

    return 0;
}

하지만 각 함수가 끝날 때 자기를 부른 객체(*this)를 되돌려주도록(반환하도록) 살짝만 고쳐볼까요?

class Calc
{
private:
    int m_value{};

public:
    Calc& add(int value) { m_value += value; return *this; }
    Calc& sub(int value) { m_value -= value; return *this; }
    Calc& mult(int value) { m_value *= value; return *this; }

    int getValue() const { return m_value; }
};

각 함수 끝에 return *this; 가 추가된 게 보이시나요? 이제 우리는 이렇게 멋진 한 줄 코드를 쓸 수 있습니다!

#include <iostream>

int main()
{
    Calc calc{};
    calc.add(5).sub(3).mult(4); // 마법의 메서드 체이닝 (기차놀이!)

    std::cout << calc.getValue() << '\n';

    return 0;
}

세 줄이나 되던 코드가 한 줄로 예쁘게 줄었죠! 어떻게 이렇게 되는지 단계별로 볼까요?

  1. 처음에 calc.add(5) 가 실행돼서 5를 더합니다. 그리고 자기를 부른 자기 자신(calc)을 그대로 뱉어냅니다.
  2. 그럼 바로 뒤에 붙어있던 .sub(3) 은 방금 뱉어진 calc 를 받아서 거기서 3을 뺍니다. 그리고 또 자기를 뱉어냅니다.
  3. 마지막으로 .mult(4) 가 받아서 4를 곱합니다.

함수가 끝날 때마다 자기 자신을 다음 타자에게 넘겨주니까, 끊기지 않고 계속 이어서 명령을 내릴 수 있는 거예요. 결국 값은 (((0 + 5) - 3) * 4) 가 되어서 8 이 저장됩니다.

(this 는 항상 지금 일하고 있는 진짜 객체를 가리키고 있기 때문에, "혹시 비어있으면(null) 어떡하지?" 하는 걱정은 안 하셔도 된답니다!)


this 는 참조(&)가 아니라 포인터(*)일까요?

설명을 듣다 보니, this 는 언제나 어떤 객체를 찰떡같이 가리키고 있잖아요? 그럼 굳이 복잡하게 포인터를 써서 화살표(->) 기호를 쓸 게 아니라, 더 깔끔한 참조(reference)를 쓰면 되지 않았나 싶으실 거예요.

정답은 아주 재미있습니다. 처음에 C++ 언어를 만들면서 this 라는 개념을 집어넣었을 그 옛날 당시에는... C++에 아직 '참조(reference)'라는 문법 자체가 존재하지 않았기 때문이에요!

만약 오늘날 C++이 새로 만들어졌다면, 틀림없이 this 를 포인터 대신 참조로 만들었을 겁니다. (실제로 Java나 C# 같은 최신 언어들은 this 를 참조 방식으로 사용하고 있어요.)


15.2 — 클래스와 헤더 파일

지금까지 우리가 작성한 모든 클래스는 아주 간단해서, 클래스 안에서 직접 함수를 만들(구현할) 수 있었습니다. 예를 들어, 아래는 모든 함수가 클래스 안에 들어있는 간단한 Date 클래스입니다.

#include <iostream>

class Date
{
private:
    int m_year{};
    int m_month{};
    int m_day{};

public:
    Date(int year, int month, int day)
        : m_year { year }
        , m_month { month }
        , m_day { day}
    {
    }

    void print() const { std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n"; }

    int getYear() const { return m_year; }
    int getMonth() const { return m_month; }
    int getDay() const { return m_day; }
};

int main()
{
    Date d { 2015, 10, 14 };
    d.print();

    return 0;
}

하지만 클래스가 점점 길어지고 복잡해지면, 모든 함수 내용을 클래스 안에 두는 것이 오히려 코드를 관리하고 작업하기 어렵게 만듭니다. 이미 만들어진 클래스를 사용할 때는 공개된 기능 이 무엇인지만 알면 되지, 그 내부가 어떻게 돌아가는지까지 전부 알 필요는 없거든요. 함수가 어떻게 작동하는지 적힌 복잡한 내용들이 섞여 있으면, 정작 이 클래스를 어떻게 써야 하는지 한눈에 파악하기 힘들어집니다.

이 문제를 해결하기 위해, C++에서는 클래스의 "선언(껍데기)" 부분과 "구현(알맹이)" 부분을 나눌 수 있게 해줍니다. 즉, 함수 내용을 클래스 밖으로 빼서 정의할 수 있는 것이죠.

아래는 위와 똑같은 Date 클래스이지만, 생성자와 print() 함수의 내용을 클래스 밖으로 빼낸 모습입니다. 클래스 안에는 "이런 함수가 있을 거야"라고 알려주는 선언만 남겨두고, 실제 작동하는 코드는 밖으로 이동시켰습니다.

#include <iostream>

class Date
{
private:
    int m_year{};
    int m_month{};
    int m_day{};

public:
    Date(int year, int month, int day); // 생성자 선언

    void print() const; // 출력 함수 선언

    int getYear() const { return m_year; }
    int getMonth() const { return m_month; }
    int getDay() const  { return m_day; }
};

Date::Date(int year, int month, int day) // 생성자 정의 (실제 구현)
    : m_year{ year }
    , m_month{ month }
    , m_day{ day }
{
}

void Date::print() const // 출력 함수 정의 (실제 구현)
{
    std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n";
};

int main()
{
    const Date d{ 2015, 10, 14 };
    d.print();

    return 0;
}

일반 함수를 만들 때처럼, 클래스에 속한 함수도 클래스 밖에서 만들 수 있습니다. 유일한 차이점은 함수 이름 앞에 이 함수가 어떤 클래스 소속인지 알려주기 위해 클래스 이름
(여기서는 Date::)을 붙여야 한다는 것입니다. 이렇게 해야 컴퓨터(컴파일러)가 "아, 이건 그냥 함수가 아니라 Date 클래스의 함수구나!"라고 이해할 수 있습니다.

참고로, 값을 읽어오기만 하는 짧은 함수(getYear 같은 접근자 함수)는 여전히 클래스 안에 남겨두었습니다. 이런 함수들은 보통 한 줄짜리라서 클래스 안에 둬도 복잡해 보이지 않거든요. 오히려 밖으로 빼면 쓸데없이 코드 줄 수만 길어집니다. 그래서 이렇게 단순한 한 줄짜리 함수들은 보통 클래스 안에 그냥 둡니다.


클래스 정의를 헤더 파일에 넣기

소스 파일(.cpp) 안에 클래스를 만들면, 그 클래스는 오직 그 파일 안에서만 쓸 수 있습니다. 하지만 프로그램이 커지면 우리가 만든 클래스를 여러 파일에서 가져다 쓰고 싶어지겠죠?

이전 강의에서 함수 선언을 헤더 파일(.h) 에 넣을 수 있다고 배웠습니다. 그렇게 하면 여러 코드 파일에서 #include를 사용해 그 함수들을 불러올 수 있었죠. 클래스도 똑같습니다! 클래스의 껍데기(선언)를 헤더 파일에 넣고, 그 클래스를 쓰고 싶은 다른 파일들에서 #include로 불러오면 됩니다.

다만 함수와는 조금 다릅니다. 함수는 이름만 미리 알려줘도 쓸 수 있지만, 클래스는 컴퓨터가 그 크기가 얼마나 되는지, 안에 어떤 변수들이 있는지 정확히 알아야 메모리를 만들 수 있습니다. 그래서 헤더 파일에는 단순히 이름만 적는 게 아니라, 클래스의 전체적인 형태(정의)를 모두 적어주어야 합니다.


클래스 헤더와 코드 파일 이름 짓기

가장 일반적인 방법은 클래스와 동일한 이름의 헤더 파일 을 만들고, 클래스 밖으로 빼낸 함수들은 동일한 이름의 .cpp 파일 에 넣는 것입니다.

우리 Date 클래스를 .h 파일과 .cpp 파일로 쪼개볼까요?

Date.h:

#ifndef DATE_H
#define DATE_H

class Date
{
private:
    int m_year{};
    int m_month{};
    int m_day{};

public:
    Date(int year, int month, int day);

    void print() const;

    int getYear() const { return m_year; }
    int getMonth() const { return m_month; }
    int getDay() const { return m_day; }
};

#endif

Date.cpp:

#include "Date.h"

Date::Date(int year, int month, int day) // 생성자 정의
    : m_year{ year }
    , m_month{ month }
    , m_day{ day }
{
}

void Date::print() const // 출력 함수 정의
{
    std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n";
};

이제 Date 클래스를 사용하고 싶은 다른 파일이 있다면, 그냥 #include "Date.h"만 적어주면 됩니다. 단, 프로그램이 제대로 합쳐지려면(링크 단계) Date.cpp 파일도 프로젝트에 포함되어 같이 번역(컴파일)되어야 한다는 점을 잊지 마세요!

모범 사례

  • 클래스의 전체적인 모양(정의)은 클래스 이름과 같은 헤더 파일에 넣으세요.
    아주 단순한 함수들은 클래스 안에 그대로 둬도 됩니다.
  • 복잡하고 긴 함수들은 클래스 이름과 같은 소스 파일(.cpp)에 따로 빼서 작성하세요.

헤더 파일에 클래스를 넣으면 규칙(ODR) 위반 아닌가요?

C++에는 "모든 것은 프로그램 전체에서 딱 한 번만 정의되어야 한다"는 단일 정의 규칙(ODR) 이 있습니다. 헤더 파일을 여러 곳에서 #include 하면 여러 번 정의되는 셈인데 괜찮을까요?

다행히도 클래스 같은 '타입(Types)'은 이 규칙에서 예외입니다! 그래서 여러 파일에서 클래스 정의를 불러와도 문제가 없습니다. 만약 이게 안 됐다면 클래스는 별로 쓸모가 없었을 거예요.
하지만 같은 파일 안에서 똑같은 클래스를 두 번 불러오는 것은 여전히 규칙 위반입니다. 이를 막기 위해 우리는 헤더 가드(#ifndef 같은 것)나 #pragma once를 사용합니다.


인라인 멤버 함수

클래스 자체는 예외지만, '함수'는 단일 정의 규칙(ODR)의 예외가 아닙니다. 그럼 헤더 파일에 함수가 들어있을 때 여러 번 불러오면 어떻게 오류를 피할 수 있을까요?

  • 클래스 안에 작성된 함수들은 자동으로 inline 처리가 됩니다. 인라인 함수는 단일 정의 규칙의 예외라서 여러 번 불러와도 괜찮습니다.
  • 클래스 밖에 작성된 함수들은 자동으로 inline 처리가 되지 않습니다. 그래서 이런 함수들을 헤더 파일에 두면 여러 번 정의되었다고 에러가 납니다. 이것이 바로 긴 함수들을 .cpp 파일에 따로 모아두는 이유입니다. (.cpp 파일은 프로그램에서 한 번만 만들어지니까요.)

만약 굳이 클래스 밖에 작성한 함수를 헤더 파일에 남겨두고 싶다면, 함수 앞에 명시적으로 inline 이라는 단어를 붙여주면 됩니다.


왜 모든 걸 헤더 파일에 넣지 않을까요?

함수 내용까지 전부 헤더 파일에 넣으면 편할 것 같지만, 두 가지 큰 단점이 있습니다.

  1. 앞서 말했듯, 클래스 모양이 너무 복잡해져서 읽기 힘들어집니다.
  2. 헤더 파일의 코드를 단 한 줄이라도 수정하면, 그 헤더를 가져다 쓴 모든 파일 을 다시 번역(컴파일)해야 합니다. 작은 프로젝트면 금방 끝나지만, 엄청나게 큰 상업용 프로그램이라면 코드를 한 줄 고치고 몇 시간씩 기다려야 할 수도 있습니다!

반면에 .cpp 파일의 코드를 수정하면, 그 .cpp 파일 딱 하나만 다시 번역하면 됩니다. 따라서 복잡한 코드는 최대한 .cpp 파일에 넣는 것이 훨씬 좋습니다.

물론 예외도 있습니다.

  • 딱 한 파일에서만 쓸 아주 작은 클래스라면 그냥 .cpp 안에 다 몰아넣어도 됩니다.
  • 복잡한 함수가 한두 개뿐이고 앞으로 수정할 일이 거의 없다면, 헤더 파일에 inline으로 넣는 게 편할 수 있습니다.
  • 요즘 C++에서는 공유하기 쉽게 아예 모든 걸 헤더 파일에 때려 넣은
    "헤더 전용(Header-only)" 라이브러리를 만들기도 합니다.
  • 나중에 배울 템플릿(Template) 클래스의 경우, 규칙상 거의 무조건 헤더 파일에 모든 걸 작성해야 합니다.

멤버 함수의 기본 인수

일반 함수의 경우 기본 인수(입력하지 않으면 자동으로 들어가는 값)는 주로 선언(헤더 파일) 쪽에 적으라고 배웠습니다. 클래스 함수의 경우는 훨씬 간단합니다.
기본 인수는 무조건 클래스 안(선언부)에 적어주세요.

모범 사례
클래스 함수의 기본 인수는 항상 클래스 정의 안에 넣으세요.


라이브러리

여러분은 이미 std::string 처럼 C++ 표준 라이브러리에 있는 클래스들을 써왔습니다. 이때 #include <string> 이라고 헤더 파일만 불러왔지, string.cpp 같은 코드 파일을 프로젝트에 추가한 적은 없으실 겁니다.

헤더 파일은 "문법이 맞는지" 컴퓨터가 확인하게 해주는 역할만 합니다. 진짜 복잡한 내부 코드는 어디 있을까요? 이미 번역이 다 끝난 파일 형태로 숨겨져 있다가, 프로그램이 완성될 때(링크 단계) 자동으로 찰싹 달라붙습니다. 그래서 여러분은 그 코드를 볼 수 없는 것이죠.

기업들이 라이브러리를 팔거나 배포할 때도 보통 .h 헤더 파일과 '미리 컴파일된 파일'만 줍니다. 매번 다시 번역하기엔 너무 오래 걸리고, 용량도 아낄 수 있으며, 무엇보다 자기들의 소중한 코드를 남이 훔쳐보지 못하게 하기 위해서입니다.

지금 당장 여러분이 이런 라이브러리를 만들 일은 없겠지만, 이렇게 헤더 파일(.h)과 소스 파일(.cpp)을 나누어 작성하는 버릇을 들이면 나중에 훌륭한 프로그래머로 성장하는 데 큰 도움이 될 것입니다.


15.3 — 중첩 타입 (멤버 타입)

다음의 짧은 프로그램을 한 번 살펴볼까요?

#include <iostream>

enum class FruitType
{
	apple,
	banana,
	cherry
};

class Fruit
{
private:
	FruitType m_type { };
	int m_percentageEaten { 0 };

public:
	Fruit(FruitType type) :
		m_type { type }
	{
	}

	FruitType getType() { return m_type; }
	int getPercentageEaten() { return m_percentageEaten; }

	bool isCherry() { return m_type == FruitType::cherry; }
};

int main()
{
	Fruit apple { FruitType::apple };

	if (apple.getType() == FruitType::apple)
		std::cout << "I am an apple";
	else
		std::cout << "I am not an apple";

	return 0;
}

이 프로그램 자체에는 아무런 문제가 없습니다.
하지만 enum class FruitType 은 원래 Fruit 클래스와 세트로 함께 쓰려고 만든 건데, 클래스 밖에 따로 덩그러니 떨어져 있다 보니 두 개가 어떻게 연결되어 있는지 우리가 직접 눈치껏 알아내야 하는 불편함이 있습니다.


중첩 타입 (멤버 타입)

지금까지 우리는 '데이터 멤버(변수)'와 '멤버 함수', 이렇게 두 가지 종류를 가진 클래스를 보았습니다. 위의 Fruit 클래스도 이 두 가지를 모두 가지고 있죠.

그런데 클래스에는 또 다른 종류의 멤버가 들어갈 수 있습니다.
바로 중첩 타입 (또는 멤버 타입) 입니다! 중첩 타입을 만드는 방법은 아주 간단해요.
그냥 클래스 안에서 원하는 접근 지정자(public, private 등) 아래에 타입을 새롭게 정의하면 됩니다.

위의 프로그램을 Fruit 클래스 안에 '중첩 타입'을 정의하는 방식으로 다시 써보겠습니다.

#include <iostream>

class Fruit
{
public:
	// FruitType을 클래스 안으로 옮기고 public 접근 지정자 아래에 두었습니다.
	// 이름도 Type으로 짧게 바꾸고, enum class 대신 일반 enum으로 변경했습니다.
	enum Type
	{
		apple,
		banana,
		cherry
	};

private:
	Type m_type {};
	int m_percentageEaten { 0 };

public:
	Fruit(Type type) :
		m_type { type }
	{
	}

	Type getType() { return m_type;  }
	int getPercentageEaten() { return m_percentageEaten;  }

	// Fruit 클래스 내부에서는 더 이상 앞에 FruitType:: 을 붙일 필요가 없습니다.
	bool isCherry() { return m_type == cherry; } 
};

int main()
{
	// 참고: 클래스 밖에서는 이제 Fruit:: 이라는 소속을 붙여서 접근합니다.
	Fruit apple { Fruit::apple };

	if (apple.getType() == Fruit::apple)
		std::cout << "I am an apple";
	else
		std::cout << "I am not an apple";

	return 0;
}

여기서 짚고 넘어갈 만한 중요한 포인트가 몇 가지 있습니다.

첫째, FruitType 이 이제 클래스 안으로 쏙 들어왔고, 이름이 Type 으로 바뀌었습니다. (이름을 왜 바꿨는지는 곧 설명해 드릴게요!)

둘째, 중첩 타입인 Type 이 클래스의 맨 꼭대기에 정의되었습니다. 중첩 타입은 사용하기 전에 먼저 그 정체가 완전히 정의되어 있어야 하거든요. 그래서 보통 맨 처음에 적어줍니다.

모범 사례
중첩 타입은 항상 클래스의 맨 위쪽에 정의하세요.

셋째, 중첩 타입도 일반적인 접근 권한 규칙을 따릅니다. Typepublic 아래에 정의되었기 때문에, 누구나 클래스 밖에서 이 타입과 그 안의 값들에 직접 접근할 수 있습니다.

넷째, 이름 공간(namespace)처럼 클래스도 그 안에 있는 이름들을 품어주는 '범위(Scope)' 역할을 합니다.

따라서 Type 의 진짜 풀네임은 Fruit::Type 이 되고, apple 의 풀네임은 Fruit::apple 이 됩니다.

클래스 '내부' 멤버들끼리는 굳이 저렇게 긴 풀네임을 쓰지 않아도 됩니다. 예를 들어 멤버 함수인 isCherry() 안에서는 Fruit:: 이라는 소속을 붙이지 않고 그냥 cherry 라고만 써도 찰떡같이 알아듣습니다.

하지만 클래스 '외부'에서는 반드시 풀네임(예: Fruit::apple)을 써야 합니다. 우리가 이름을 FruitType 에서 Type 으로 바꾼 이유가 바로 이겁니다. 밖에서 부를 때 Fruit::FruitType 이라고 쓰면 이름이 쓸데없이 겹치니까, 깔끔하게 Fruit::Type 이라고 쓸 수 있게 만든 거죠!

마지막으로, 기존의 범위가 있는 열거형(enum class)을 일반 열거형(enum)으로 바꾸었습니다. 이제 Fruit 클래스 자체가 울타리(범위) 역할을 든든하게 해주기 때문에, 굳이 enum class 를 써서 이중으로 울타리를 칠 필요가 없어진 거죠. 일반 enum 으로 바꾸었기 때문에 밖에서 Fruit::Type::apple 처럼 길게 안 쓰고 Fruit::apple 처럼 짧고 편하게 쓸 수 있습니다.


중첩 typedef 및 타입 별칭

클래스 안에는 typedefusing 을 이용한 '타입 별칭(별명)'도 넣을 수 있습니다.

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
public:
    using IDType = int;

private:
    std::string m_name{};
    IDType m_id{};
    double m_wage{};

public:
    Employee(std::string_view name, IDType id, double wage)
        : m_name { name }
        , m_id { id }
        , m_wage { wage }
    {
    }

    const std::string& getName() { return m_name; }
    IDType getId() { return m_id; } // 클래스 안에서는 짧은 이름만 써도 됩니다.
};

int main()
{
    Employee john { "John", 1, 45000 };
    Employee::IDType id { john.getId() }; // 클래스 밖에서는 반드시 풀네임을 써야 합니다.

    std::cout << john.getName() << " has id: " << id << '\n';

    return 0;
}

이 코드를 실행하면 다음과 같이 출력됩니다.

John has id: 1

보시다시피 클래스 '안'에서는 그냥 IDType 이라고 편하게 쓰면 되지만, 클래스 '밖'에서는 Employee::IDType 처럼 정확한 풀네임을 써야 한다는 점을 기억해 주세요.

(타입 별칭의 장점은 10.7 레슨에서 다루었는데, 여기서도 같은 역할을 합니다. C++ 표준 라이브러리에서도 이런 중첩 typedef 를 아주 흔하게 사용합니다. 글을 쓰는 현재 기준으로 std::string 은 무려 10개나 되는 중첩 typedef 를 가지고 있답니다!)


중첩 클래스와 바깥 클래스 멤버 접근

클래스 안에 '또 다른 클래스'를 중첩해서 넣는 일은 꽤 드물지만, 가능하긴 합니다. C++에서 중첩 클래스(안쪽 클래스)는 자기를 감싸고 있는 바깥쪽 클래스의 this 포인터에 접근할 수 없습니다. 즉, 안쪽 클래스가 바깥 클래스의 멤버 변수나 함수를 '직접' 마음대로 꺼내 쓸 수는 없다는 뜻입니다. 왜냐하면 안쪽 클래스는 바깥 클래스 객체가 없어도 독립적으로 만들어질 수 있기 때문이에요. (바깥 클래스 객체가 아예 존재하지 않는 상황일 수도 있으니까요!)

하지만 안쪽 클래스도 어쨌든 바깥 클래스 식구(멤버) 중 하나이기 때문에, 바깥 클래스의 private 멤버에 접근할 수 있는 특별한 권한 자체는 가지고 있습니다. 단지 어떤 객체의 데이터인지 명확히 알려주기만 하면 됩니다.

예제를 통해 살펴볼까요?

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
public:
    using IDType = int;

    class Printer
    {
    public:
        void print(const Employee& e) const
        {
            // Printer는 Employee의 'this' 포인터에 접근할 수 없기 때문에,
            // m_name과 m_id를 직접 출력할 수는 없습니다.
            // 대신, 사용할 Employee 객체를 통째로 전달받아야 합니다.
            // Printer는 Employee의 한 식구(멤버)이므로, 
            // 전달받은 객체(e)를 통하면 private 멤버인 e.m_name과 e.m_id에 직접 접근할 수 있습니다.
            std::cout << e.m_name << " has id: " << e.m_id << '\n';
        }
    };

private:
    std::string m_name{};
    IDType m_id{};
    double m_wage{};

public:
    Employee(std::string_view name, IDType id, double wage)
        : m_name{ name }
        , m_id{ id }
        , m_wage{ wage }
    {
    }

    // 이 예제에서는 사용되지 않으므로 접근 함수(getter)들을 제거했습니다.
};

int main()
{
    const Employee john{ "John", 1, 45000 };
    const Employee::Printer p{}; // 안쪽(중첩) 클래스의 객체를 생성합니다.
    p.print(john);

    return 0;
}

이 코드를 실행하면 다음과 같이 출력됩니다.

John has id: 1

중첩 클래스가 자주 쓰이는 아주 대표적인 예외 상황이 하나 있습니다. 바로 표준 라이브러리인데요, 데이터 모음(컨테이너)을 순회할 때 쓰는 대부분의 '반복자(iterator)' 클래스들이 자신이 탐색할 컨테이너의 중첩 클래스로 만들어져 있습니다. 예를 들어 std::string::iteratorstd::string 의 중첩 클래스랍니다. (반복자는 나중에 다른 장에서 자세히 다룰게요.)


중첩 타입과 전방 선언

전방 선언이란, 컴파일러에게 "이런 타입이 앞으로 나올 테니까 놀라지 마"라고 이름만 미리 알려주는 것을 말합니다. 중첩 타입은 자기를 감싸고 있는 바깥 클래스 안에서 미리 전방 선언을 할 수 있습니다. 그러고 나서 나중에 바깥 클래스 안이나 밖에서 그 내용을 진짜로 정의하면 됩니다.

예를 들면 이렇게요.

#include <iostream>

class outer
{
public:
    class inner1;   // 정상: 감싸는 클래스 안에서 이름만 미리 전방 선언 가능
    class inner1{}; // 정상: 전방 선언했던 타입을 감싸는 클래스 안에서 정의
    class inner2;   // 정상: 감싸는 클래스 안에서 전방 선언 가능
};

class outer::inner2 // 정상: 전방 선언했던 타입을 감싸는 클래스 '밖'에서 정의
{};

int main()
{
    return 0;
}

하지만, 바깥 클래스 자체가 어떻게 생겼는지 정의되기도 전에 그 안에 들어갈 중첩 타입만 쏙 빼서 먼저 전방 선언할 수는 없습니다.

#include <iostream>

class outer;         // 정상: 중첩되지 않은 일반 클래스는 전방 선언 가능
class outer::inner1; // 오류: 바깥 클래스(outer)가 정의되기도 전에 중첩 타입(inner1)을 전방 선언할 수 없음

class outer
{
public:
    class inner1{}; // 참고: 여기서 중첩 타입이 선언됨
};

class outer::inner1; // 정상 (하지만 무의미함): 이미 바깥 클래스를 정의할 때 중첩 타입도 같이 선언되었기 때문

int main()
{
    return 0;
}

바깥 클래스를 정의한 '후'에 중첩 타입을 한 번 더 전방 선언하는 것은 기술적으로 오류는 나지 않지만, 이미 바깥 클래스 안에 중첩 타입에 대한 선언이 들어있을 테니 두 번 쓰는 셈이 되어 아무런 의미가 없습니다 (중복).


15.4 — 소멸자 소개

정리 문제
네트워크를 통해 데이터를 보내는 프로그램을 만들고 있다고 상상해 봅시다. 서버에 연결을 맺는 과정은 컴퓨터 입장에서 꽤 힘든(비용이 많이 드는) 작업입니다. 그래서 데이터를 매번 찔끔찔끔 보내기보다는, 한 번에 모아두었다가 왕창 보내고 싶을 겁니다. 이런 역할을 하는 클래스는 아마 아래 코드처럼 생겼을 거예요.

// 이 예제는 (의도적으로) 불완전하기 때문에 컴파일되지 않습니다.
class NetworkData
{
private:
    std::string m_serverName{};
    DataStore m_dataQueue{};

public:
	NetworkData(std::string_view serverName)
		: m_serverName { serverName }
	{
	}

	void addData(std::string_view data)
	{
		m_dataQueue.add(data);
	}

	void sendData()
	{
		// 서버에 연결
		// 모든 데이터 전송
		// 데이터 비우기
	}
};

int main()
{
    NetworkData n("someipAddress");

    n.addData("somedata1");
    n.addData("somedata2");

    n.sendData();

    return 0;
}

하지만 이 NetworkData 클래스에는 잠재적인 폭탄이 하나 숨어 있습니다. 바로 프로그램이 종료되기 전에 무조건 sendData() 함수를 '직접' 호출해 주어야 한다는 점입니다. 만약 이 클래스를 사용하는 사람이 깜빡 잊고 함수 호출을 안 하면, 데이터는 서버로 가지 못하고 프로그램이 꺼질 때 그대로 허공으로 날아가 버립니다.

"에이, 저렇게 뻔히 보이는데 함수 부르는 걸 잊어버리겠어?" 라고 생각하실 수도 있습니다. 네, 이 짧은 예제에서는 맞습니다. 하지만 아래 함수처럼 상황이 조금만 더 복잡해진다면 어떨까요?

bool someFunction()
{
    NetworkData n("someipAddress");

    n.addData("somedata1");
    n.addData("somedata2");

    if (someCondition)
        return false;

    n.sendData();
    return true;
}

이 코드를 볼까요? 만약 someCondition 이라는 조건이 참(true)이라면, 중간에 있는 return false;를 만나 함수가 일찍 끝나버립니다! 즉, 맨 밑에 있는 n.sendData(); 는 영영 실행되지 못하는 것이죠. 코드는 멀쩡히 잘 적혀 있지만, 특정 상황에서는 데이터 전송이 누락되어 버립니다. 프로그래밍을 하다 보면 이런 실수는 정말 흔하게 일어납니다.

이 문제를 좀 더 크게 바라봅시다. 메모리, 파일, 데이터베이스, 네트워크 연결 같은 자원(Resource) 을 사용하는 클래스들은, 객체(클래스로 만든 결과물)가 다 쓰이고 파괴되기 전에 반드시 문을 닫거나 전송을 마치는 등의 '정리' 작업이 필요합니다. 때로는 객체가 사라지기 전에 로그 파일에 기록을 남기거나, 서버에 정보를 보내야 할 수도 있죠.

이처럼 객체가 사라지기 전에 프로그램이 정상적으로 돌아가게 하려고 꼭 거쳐야 하는 모든 뒷수습 작업을 묶어서 정리(Clean up) 라고 부릅니다. 만약 이 중요한 뒷정리를 사용하는 사람(프로그래머)의 기억력에만 의존해서 직접 함수를 부르게 만든다면, 언젠가는 반드시 버그가 터지게 되어 있습니다.

그런데 애초에 왜 이걸 사람이 일일이 신경 써야 할까요? 객체가 수명을 다해 파괴될 때가 되었다면, "아, 이제 끝났으니 내가 알아서 뒷정리해야지!" 하고 자동으로 실행되게 할 수는 없을까요?

소멸자가 해결해 드립니다!
이전 강의인 '14.9 — 생성자 소개'에서 생성자(Constructor) 에 대해 배웠습니다. 생성자는 객체가 처음 태어날 때(만들어질 때) 자동으로 불려 와서 기초 설정을 해주는 특별한 함수죠.

이와 아주 비슷하게, 클래스에는 객체가 '파괴되어 사라질 때' 알아서 자동으로 호출되는 또 다른 특별한 함수가 있습니다. 이것을 바로 소멸자(Destructor) 라고 부릅니다. 소멸자 는 객체가 완전히 사라지기 직전에 필요한 모든 청소와 뒷정리를 스스로 하도록 설계되었습니다. (생성자가 '탄생'이라면 소멸자는 '죽음'을 담당한다고 보시면 됩니다!)

소멸자 이름 짓는 법
생성자처럼, 소멸자도 이름을 짓는 엄격한 규칙이 있습니다:

  • 소멸자의 이름은 클래스 이름과 완전히 똑같아야 하며, 맨 앞에 물결표(~)를 붙여야 합니다.
  • 소멸자는 어떤 값(인수)도 전달받을 수 없습니다. (괄호 안이 비어있어야 합니다.)
  • 소멸자는 반환 타입(return type)이 아예 없습니다.
  • 하나의 클래스에는 단 하나의 소멸자만 만들 수 있습니다.

또한, 소멸자를 코드에서 여러분이 '직접' 부르시면 안 됩니다. 객체가 파괴될 때 어차피 컴퓨터가 알아서 자동으로 불러주기 때문입니다. 굳이 똑같은 뒷정리를 두 번 할 필요는 없으니까요.

참고로, 소멸자 안에서 클래스의 다른 함수를 부르는 것은 아주 안전합니다.
소멸자 코드가 끝날 때까지는 객체가 아직 완전히 파괴된 것이 아니기 때문입니다.

소멸자 예제
코드로 직접 확인해 볼까요?

#include <iostream>

class Simple
{
private:
    int m_id {};

public:
    Simple(int id)
        : m_id { id }
    {
        std::cout << "Constructing Simple " << m_id << '\n';
    }

    ~Simple() // 여기가 우리의 소멸자입니다
    {
        std::cout << "Destructing Simple " << m_id << '\n';
    }

    int getID() const { return m_id; }
};

int main()
{
    // Simple 객체 할당
    Simple simple1{ 1 };
    {
        Simple simple2{ 2 };
    } // simple2는 여기서 사라집니다

    return 0;
} // simple1은 여기서 사라집니다

이 프로그램을 실행하면 다음과 같은 결과가 나옵니다:

Constructing Simple 1
Constructing Simple 2
Destructing Simple 2
Destructing Simple 1

결과를 잘 보세요. 각 Simple 객체가 파괴될 때 자동으로 소멸자가 불려 와서 메시지를 찍습니다. 눈여겨볼 점은 "Destructing Simple 2"가 1번보다 먼저 출력된다는 점입니다!
왜 그럴까요? simple2는 자기를 감싸고 있는 안쪽 중괄호 { } 가 끝나는 순간 수명을 다해 먼저 파괴되기 때문입니다. 반면에 simple1main() 함수가 완전히 끝날 때까지 살아있다가 제일 마지막에 파괴됩니다.

(기억해 두세요: 전역 변수나 정적 지역 변수 같은 정적 변수들은 프로그램이 켜질 때 만들어지고, 프로그램이 완전히 꺼질 때 파괴됩니다.)

NetworkData 프로그램 개선하기
자, 이제 강의 맨 처음에 봤던 골칫거리 코드로 돌아가 봅시다. 사용자가 까먹고 sendData() 를 안 부를까 봐 조마조마했던 부분을 소멸자로 시원하게 고쳐보겠습니다!

class NetworkData
{
private:
    std::string m_serverName{};
    DataStore m_dataQueue{};

public:
	NetworkData(std::string_view serverName)
		: m_serverName { serverName }
	{
	}

	~NetworkData()
	{
		sendData(); // 객체가 파괴되기 전에 모든 데이터가 전송되도록 보장합니다
	}

	void addData(std::string_view data)
	{
		m_dataQueue.add(data);
	}

	void sendData()
	{
		// 서버에 연결
		// 모든 데이터 전송
		// 데이터 비우기
	}
};

int main()
{
    NetworkData n("someipAddress");

    n.addData("somedata1");
    n.addData("somedata2");

    return 0;
}

이제 NetworkData 안에 소멸자가 생겼습니다! 덕분에 객체가 파괴되기 직전에, 자기가 가지고 있던 모든 데이터를 '무조건' 서버로 알아서 보내고 깔끔하게 생을 마감할 것입니다. 마무리 정리가 100% 자동으로 이루어지니, 여러분은 신경 쓸 일도 줄어들고 오류가 날 확률도 획기적으로 낮아졌습니다.

암시적 소멸자 (자동 생성 소멸자)
만약 프로그래머가 클래스를 만들면서 소멸자를 깜빡하고(혹은 안 필요해서) 안 만들면 어떻게 될까요? C++ 컴파일러가 알아서 아무 내용도 없는 텅 빈 소멸자를 몰래 하나 만들어 줍니다. 이것을 암시적 소멸자(Implicit destructor) 라고 부르며, 그냥 자리만 차지하는 가짜 소멸자라고 생각하시면 됩니다.

만약 여러분이 만든 클래스가 파괴될 때 딱히 특별하게 청소할 거리가 없다면, 굳이 소멸자를 안 만드셔도 전혀 문제없습니다. 컴파일러가 알아서 빈 소멸자를 만들도록 내버려 두면 되니까요.

std::exit() 함수에 대한 경고
이전 강의인 '8.12 — 프로그램 일찍 종료하기'에서 std::exit() 라는 함수를 배웠습니다. 이 함수는 프로그램을 그 즉시 강제로 확 꺼버리는 기능이 있습니다.
문제는 프로그램이 이렇게 비상 종료될 때는, 사용 중이던 지역 변수들이 정상적인 파괴 과정을 거치지 못한다는 점입니다. 즉, 소멸자 가 호출되지 않고 그냥 날아가 버립니다! 만약 소멸자가 파일 저장 등 아주 중요한 뒷정리를 하도록 믿고 맡겨두셨다면, 이런 상황에서는 뒤통수를 맞을 수 있으니 꼭 주의해야 합니다.

고급 독자를 위한 정보
처리되지 않은 예외(Unhandled exceptions)로 인해 프로그램이 터져서 죽어버릴 때도 마찬가지입니다. 이때도 스택 풀기(Stack unwinding, 함수 호출 기록을 거꾸로 되짚어 가며 정리하는 과정)가 일어나지 않는다면, 프로그램 종료 전에 소멸자가 호출되지 않을 수 있습니다.


15.6 — 정적 멤버 변수

이전 레슨인 7.4 (전역 변수 소개)와 7.11 (정적 지역 변수)에서 우리는 전역 변수와 정적 지역 변수에 대해 배웠습니다. 이 두 종류의 변수는 모두 정적 지속 시간을 가집니다. 즉, 프로그램이 시작될 때 딱 만들어져서, 프로그램이 완전히 끝날 때 파괴된다는 뜻이에요. 이런 변수들은 자신이 속한 영역(스코프)을 벗어나더라도 자신의 값을 그대로 기억하고 유지한답니다.

예를 들어볼게요.

#include <iostream>

int generateID()
{
    static int s_id{ 0 }; // 정적 지역 변수 (static local variable)
    return ++s_id;
}

int main()
{
    std::cout << generateID() << '\n';
    std::cout << generateID() << '\n';
    std::cout << generateID() << '\n';

    return 0;
}

이 프로그램을 실행하면 화면에 이렇게 나옵니다:

1
2
3

여기서 정적 지역 변수인 s_id가 함수가 여러 번 호출되는 동안에도 초기화되지 않고 자신의 값을 계속 유지하고 있다는 점을 주목해 주세요.

클래스(Class)를 사용하면 이 static (정적) 키워드를 쓸 수 있는 곳이 두 군데 더 생깁니다. 바로 정적 멤버 변수정적 멤버 함수 입니다. 다행히도 이 개념들은 꽤 직관적이고 이해하기 쉬워요. 이번 레슨에서는 정적 멤버 변수에 대해 알아보고, 다음 레슨에서 정적 멤버 함수를 다루겠습니다.


정적 멤버 변수

static 키워드를 멤버 변수에 어떻게 쓰는지 알아보기 전에, 먼저 아주 평범한 아래의 클래스를 살펴볼게요.

#include <iostream>

struct Something
{
    int value{ 1 };
};

int main()
{
    Something first{};
    Something second{};

    first.value = 2;

    std::cout << first.value << '\n';
    std::cout << second.value << '\n';

    return 0;
}

우리가 클래스로 객체(Object)를 만들 때, 각각의 객체는 자신만의 '일반 멤버 변수' 복사본을 가집니다. 마치 각자 개인 수첩을 하나씩 나눠 갖는 것과 같아요. 위 코드에서는 Something 클래스 객체를 두 개(firstsecond) 만들었기 때문에, value라는 변수도 first.valuesecond.value 두 개가 생깁니다. 이 둘은 완전히 서로 다른 변수예요.

그래서 위 프로그램의 결과는 다음과 같습니다:

2
1

하지만 static 키워드를 사용하면 멤버 변수를 '정적'으로 만들 수 있습니다. 개인 수첩을 갖는 일반 변수와 달리, 정적 멤버 변수 는 클래스로 만든 모든 객체들이 다 함께 사용하는 '공유 칠판' 같은 역할을 합니다.

위의 코드와 비슷하지만 static이 들어간 아래 프로그램을 살펴볼까요?

#include <iostream>

struct Something
{
    static int s_value; // s_value를 static으로 선언합니다 (초기화는 아래로 이동됨)
};

int Something::s_value{ 1 }; // s_value를 정의하고 1로 초기화합니다 (이 부분은 아래에서 다룰게요)

int main()
{
    Something first{};
    Something second{};

    first.s_value = 2;

    std::cout << first.s_value << '\n';
    std::cout << second.s_value << '\n';

    return 0;
}

이 프로그램의 결과는 다음과 같습니다:

2
2

s_value가 정적 멤버 변수이기 때문에, 클래스의 모든 객체가 이 변수를 공유합니다. 즉, first.s_valuesecond.s_value는 사실 똑같은 하나의 변수를 가리키고 있는 거예요! 위 코드를 보면 first를 이용해 값을 2로 바꿨는데, second를 이용해서도 그 바뀐 값(2)을 똑같이 확인할 수 있습니다.


정적 멤버는 특정 객체에 묶여있지 않습니다

위의 예제처럼 객체(firstsecond)를 통해서 정적 멤버에 접근할 수도 있지만, 사실 정적 멤버 는 객체를 단 하나도 만들지 않은 상태에서도 이미 존재합니다!
가만히 생각해 보면 당연한 일이에요. 정적 변수들은 프로그램이 시작될 때 만들어지고 끝날 때 파괴되기 때문에, 일반 멤버 변수들처럼 특정 객체가 만들어지고 사라지는 타이밍에 얽매이지 않거든요.

쉽게 말해, 정적 멤버는 클래스라는 울타리 안에 살고 있는 전역 변수 라고 생각하시면 됩니다. 클래스 안에 있는 정적 멤버나, 네임스페이스(namespace) 안에 있는 일반 변수나 사실상 거의 차이가 없어요.

핵심 포인트
정적 멤버는 클래스의 범위(Scope) 안에 존재하는 전역 변수입니다.

s_value라는 정적 멤버 변수는 특정 객체와 상관없이 독립적으로 존재하기 때문에, 클래스 이름과 범위 지정 연산자(::)를 사용해서 직접 접근할 수 있습니다. (예: Something::s_value)

class Something
{
public:
    static int s_value; // s_value를 static으로 선언합니다
};

int Something::s_value{ 1 }; // s_value를 정의하고 1로 초기화합니다 (이 부분은 아래에서 다룰게요)

int main()
{
    // 주의: 우리는 Something 타입의 객체를 전혀 만들지 않았습니다!

    Something::s_value = 2;
    std::cout << Something::s_value << '\n';
    return 0;
}

위 코드에서는 객체를 통하지 않고 클래스 이름인 Something을 이용해 s_value를 사용했습니다. 우리가 Something 객체를 단 하나도 만들지 않았는데도 Something::s_value를 사용하고 값을 바꿀 수 있죠? 정적 멤버를 사용할 때는 이렇게 클래스 이름을 통해 접근하는 것이 가장 좋은 방법입니다.

모범 사례
정적 멤버에 접근할 때는 항상 클래스 이름과 범위 지정 연산자(::)를 사용하세요.
(예: Something::s_value)


정적 멤버 변수 정의하고 초기화하기

클래스 안에서 정적 멤버 변수를 선언(static int s_value;)하는 것은 컴퓨터에게 "이런 정적 변수가 존재할 거야~"라고 알려주는 역할만 할 뿐, 실제로 변수를 만들어내는(정의하는) 것은 아닙니다.
정적 멤버 변수는 사실상 전역 변수와 같기 때문에, 반드시 클래스 밖(전역 범위)에서 명시적으로 정의를 해주고 값을 초기화해 주어야 합니다.

위 예제에서는 이 코드가 그 역할을 했죠:

int Something::s_value{ 1 }; // s_value를 정의하고 1로 초기화합니다

이 한 줄의 코드는 두 가지 역할을 합니다. 전역 변수처럼 정적 멤버 변수를 실제로 만들어내고, 거기에 1이라는 초기값을 넣어주는 거죠. 만약 초기값을 따로 주지 않으면 기본적으로 0으로 채워집니다.

참고로 이렇게 밖에서 정의할 때는 접근 제어자(private, protected 등)의 영향을 받지 않습니다. 클래스 안에서 private으로 숨겨놨더라도, 밖에서 정의하고 초기화하는 것은 문제가 안 돼요. (정의하는 행위 자체는 접근으로 치지 않거든요.)


클래스 안에서 정적 멤버 변수 초기화하기 (지름길)

항상 클래스 밖에서 초기화해야 한다면 귀찮겠죠? 몇 가지 예외적인 지름길이 있습니다.
첫 번째로, 정적 멤버가 상수 정수형 (여기엔 charbool도 포함돼요)이거나 const enum일 때는 클래스 안에서 바로 초기화할 수 있습니다.

class Whatever
{
public:
    static const int s_value{ 4 }; // static const int는 클래스 안에서 바로 정의하고 초기화할 수 있습니다
};

또한 C++17부터는 inline이라는 마법의 키워드를 사용할 수 있습니다. inline 변수를 사용하면 상수인지 아닌지에 상관없이 클래스 안에서 곧바로 초기화할 수 있어서 아주 편리합니다. 최근에는 이 방법이 가장 권장됩니다.

class Whatever
{
public:
    static inline int s_value{ 4 }; // static inline 변수는 바로 정의하고 초기화할 수 있습니다
};

constexpr 멤버는 C++17부터 자동으로 inline 취급을 받기 때문에, 굳이 inline이라고 쓰지 않아도 클래스 내부에서 초기화가 가능합니다.

#include <string_view>

class Whatever
{
public:
    static constexpr double s_value{ 2.2 }; // 문제 없음!
    static constexpr std::string_view s_view{ "Hello" }; // constexpr 초기화를 지원하는 클래스에서도 잘 작동합니다
};

모범 사례
정적 멤버 변수는 inline이나 constexpr로 만들어서 클래스 내부에서 깔끔하게 초기화하는 것을 추천합니다.


정적 멤버 변수는 언제 쓸까요? (예제)

그렇다면 클래스 안에 정적 변수를 왜 쓸까요? 가장 대표적인 쓰임새는 클래스로 찍어내는 모든 객체들에게 고유한 ID 번호 를 부여할 때입니다.

#include <iostream>

class Something
{
private:
    static inline int s_idGenerator { 1 };
    int m_id {};

public:
    // id 생성기에서 다음 값을 가져옵니다
    Something() : m_id { s_idGenerator++ }
    {
    }

    int getID() const { return m_id; }
};

int main()
{
    Something first{};
    Something second{};
    Something third{};

    std::cout << first.getID() << '\n';
    std::cout << second.getID() << '\n';
    std::cout << third.getID() << '\n';
    return 0;
}

결과는 다음과 같습니다:

1
2
3

s_idGenerator는 모든 Something 객체가 공유하는 정적 변수입니다. 새로운 객체가 만들어질 때마다 생성자가 이 값을 객체의 개인 ID(m_id)로 저장하고, 다음 객체를 위해 공유 값인 s_idGenerator를 1 증가시킵니다. 이렇게 하면 새로 생겨나는 모든 객체들이 절대 겹치지 않는 자신만의 고유한 ID를 가질 수 있게 되죠!

정적 멤버 변수는 조회 테이블 을 만들 때도 매우 유용합니다. 복잡한 계산 값을 미리 배열에 저장해 두고 필요할 때마다 꺼내 쓴다고 가정해 보세요. 이 배열을 일반 멤버로 만들면 객체를 100개 만들 때 배열도 100개가 생겨서 메모리 낭비가 심해집니다. 하지만 정적(static) 멤버로 만들면, 객체가 몇 개든 상관없이 배열은 딱 1개만 존재해서 모든 객체가 공유하므로 메모리를 크게 아낄 수 있습니다.


오직 정적 멤버만 타입 추론(auto 와 CTAD)을 쓸 수 있습니다

C++에는 타입을 알아서 맞춰주는 편리한 기능인 auto나, 템플릿 타입을 유추해 주는 CTAD라는 기능이 있습니다.
초보자분들은 "아, 편한 기능이구나" 정도로만 아셔도 충분해요! 중요한 건, 이런 편리한 타입 추론 기능은 오직 정적 멤버에만 쓸 수 있고 일반(비정적) 멤버에는 쓸 수 없다는 점입니다.

왜 그런지 깊게 들어가면 많이 복잡해지지만, 간단히 말해 일반 멤버에 이 기능을 허용하면 컴퓨터가 너무 헷갈려 하는 상황이 발생하기 때문입니다. 반면 정적 멤버는 규칙이 단순해서 헷갈릴 일이 없거든요.

#include <utility> // std::pair<T, U>를 사용하기 위해 필요합니다

class Foo
{
private:
    auto m_x { 5 };           // 에러: 일반(비정적) 멤버는 auto를 쓸 수 없습니다
    std::pair m_v { 1, 2.3 }; // 에러: 일반 멤버는 CTAD를 쓸 수 없습니다

    static inline auto s_x { 5 };           // 성공: 정적 멤버는 auto를 쓸 수 있습니다
    static inline std::pair s_v { 1, 2.3 }; // 성공: 정적 멤버는 CTAD를 쓸 수 있습니다

public:
    Foo() {};
};

int main()
{
    Foo foo{};

    return 0;
}

15.7 — 정적 멤버 함수

이전 학습인 '15.6 - 정적(Static) 멤버 변수'에서, 정적 멤버 변수는 특정 '객체(붕어빵)'가 아니라 '클래스(붕어빵 틀)' 자체에 속한다는 걸 배웠어요. 만약 이 정적 변수가 공개(public)되어 있다면, 클래스 이름과 범위 지정 연산자(::)를 써서 바로 꺼내 쓸 수 있습니다.

#include <iostream>

class Something
{
public:
    static inline int s_value { 1 };
};

int main()
{
    std::cout << Something::s_value; // s_value는 public이므로 직접 접근할 수 있어요!
}

하지만 정적 멤버 변수가 비공개(private)라면 어떨까요? 다음 예시를 볼까요?

#include <iostream>

class Something
{
private: // 이제 private입니다!
    static inline int s_value { 1 };
};

int main()
{
    std::cout << Something::s_value; // 오류: s_value는 private이라서 클래스 밖에서 직접 접근할 수 없어요.
}

이런 경우에는 s_value가 private이기 때문에 main() 함수에서 직접 가져다 쓸 수 없어요. 보통 우리는 private 변수를 꺼내 올 때 public 함수를 사용하죠. 그래서 s_value를 꺼내기 위한 평범한 public 함수를 만들 수는 있겠지만, 그렇게 되면 그 함수를 쓰기 위해 굳이 객체(붕어빵) 를 하나 새로 만들어야 하는 번거로움이 생깁니다!

#include <iostream>

class Something
{
private:
    static inline int s_value { 1 };

public:
    int getValue() { return s_value; }
};

int main()
{
    Something s{};
    std::cout << s.getValue(); // 작동은 하지만, getValue()를 호출하려면 굳이 객체를 만들어야 해요.
}

우리는 더 똑똑한 방법을 쓸 수 있습니다.


정적 멤버 함수

변수에만 static을 붙일 수 있는 게 아니에요. 함수에도 똑같이 붙일 수 있답니다. 방금 본 예시에 정적 멤버 함수를 적용해 볼게요.

#include <iostream>

class Something
{
private:
    static inline int s_value { 1 };

public:
    static int getValue() { return s_value; } // 정적 멤버 함수
};

int main()
{
    std::cout << Something::getValue() << '\n';
}

정적 멤버 함수는 특정 객체(붕어빵)에 묶여 있지 않아요. 그래서 굳이 객체를 만들지 않고도 클래스 이름과 :: 기호만 써서 바로 호출할 수 있답니다 (예: Something::getValue()). 정적 변수와 마찬가지로 객체를 통해서 부를 수도 있지만, 헷갈릴 수 있어서 추천하지 않아요.


정적 멤버 함수에는 this 포인터가 없어요!

정적 멤버 함수에는 꼭 기억해야 할 두 가지 흥미로운 특징이 있어요.

첫째, 정적 멤버 함수는 특정 객체에 소속된 게 아니기 때문에 this 포인터 가 없습니다! 생각해보면 당연해요. this는 보통 '지금 이 작업을 하고 있는 그 객체'를 가리키는데, 정적 함수는 애초에 객체 위에서 돌아가는 게 아니니까 this가 필요 없죠.

둘째, 정적 멤버 함수는 다른 정적 멤버(변수나 함수)에는 자유롭게 접근할 수 있지만, 일반(non-static) 멤버 에는 접근할 수 없어요. 일반 멤버는 반드시 어떤 객체 안에 존재해야 하는데, 정적 함수는 작업할 객체 자체가 없기 때문이에요!


클래스 밖에서 정적 멤버 정의하기

정적 멤버 함수도 일반 멤버 함수처럼 클래스 바깥에서 그 내용을 정의할 수 있어요. 방법은 똑같습니다.

#include <iostream>

class IDGenerator
{
private:
    static inline int s_nextID { 1 };

public:
     static int getNextID(); // 정적 함수의 선언부입니다.
};

// 여기는 클래스 밖에서 정적 함수를 정의하는 부분입니다. 여기서 static 키워드는 쓰지 않아요!
int IDGenerator::getNextID() { return s_nextID++; }

int main()
{
    for (int count{ 0 }; count < 5; ++count)
        std::cout << "The next ID is: " << IDGenerator::getNextID() << '\n';

    return 0;
}

이 프로그램의 출력 결과는 다음과 같습니다:

The next ID is: 1
The next ID is: 2
The next ID is: 3
The next ID is: 4
The next ID is: 5

이 클래스 안의 모든 데이터와 함수가 static이기 때문에, 이 기능을 쓰기 위해 굳이 객체를 만들 필요가 없다는 점을 꼭 기억하세요! 이 클래스는 정적 멤버 변수로 다음 ID 값을 기억하고, 정적 멤버 함수로 그 값을 반환하면서 숫자를 1씩 올려줍니다.

15.2 학습에서 다루었듯이, 클래스 안에서 만든 함수는 자동으로 인라인(inline) 처리가 되지만, 클래스 밖에서 정의한 함수는 그렇지 않아요. 그래서 헤더 파일 안에 클래스 밖에서 정의한 정적 멤버 함수를 넣을 때는, 여러 파일에서 불러오더라도 에러(ODR 위반)가 나지 않게 꼭 inline 키워드를 붙여주는 게 좋습니다.


모든 멤버가 정적인 클래스를 만들 때 주의할 점

모든 멤버가 static인 클래스(일명 '순수 정적 클래스')를 만들 때는 조심해야 해요. 유용할 때도 있지만, 단점도 있거든요.

첫째, 모든 정적 멤버는 딱 한 번만 만들어지기 때문에, 여러 개의 독립적인 버전을 만들 수 없어요. 예를 들어, 서로 완전히 다르게 작동하는 2개의 IDGenerator 가 필요하더라도, 순수 정적 클래스로는 불가능하죠.

둘째, 전역(Global) 변수 학습에서 배웠듯이, 전역 변수는 누군가 어디서든 값을 바꿀 수 있어서 예상치 못한 에러를 낼 수 있기 때문에 위험합니다. 순수 정적 클래스도 똑같아요. 사실상 전역 변수나 전역 함수를 모아둔 것과 비슷해서 이런 부작용을 똑같이 겪을 수 있습니다.

그래서 무작정 모든 걸 static으로 만들기보다는, 평범한 일반 클래스를 하나 만들어서 그걸 전역(Global) 객체로 하나 생성하는 방식이 더 나을 수 있어요. 그러면 필요할 때 전역 객체를 쓰면서도, 언제든 독립적인 개별 객체를 여러 개 만들어서 쓸 수 있으니까요.


순수 정적 클래스 vs 네임스페이스(Namespace)

순수 정적 클래스는 네임스페이스와 아주 비슷해요. 둘 다 그 안에서 전역적으로 쓰이는 변수나 함수를 정의할 수 있죠. 하지만 가장 큰 차이점은 클래스는 접근 제어(private, public) 가 가능하고, 네임스페이스는 그게 안 된다는 거예요.

결론적으로, 외부에서 못 보게 숨겨야 할 데이터가 있거나 접근 제어가 필요하다면 정적 클래스 를 쓰고, 그게 아니라면 네임스페이스 를 쓰는 게 좋습니다.


C++에는 정적(Static) 생성자가 없어요

일반 변수들을 생성자로 초기화하는 것처럼, 정적 변수도 '정적 생성자'로 초기화하면 좋겠다고 생각할 수 있어요. 다른 언어들에는 이런 기능이 있지만, 안타깝게도 C++에는 없습니다.

대신 정적 변수는 정의하는 순간 바로 값을 넣어(초기화) 줄 수 있어요 (심지어 private이라도 말이죠!). 위의 IDGenerator 예제에서도 그렇게 했고, 아래 다른 예시도 확인해 보세요.

#include <iostream>

struct Chars
{
    char first{};
    char second{};
    char third{};
    char fourth{};
    char fifth{};
};

struct MyClass
{
	static inline Chars s_mychars { 'a', 'e', 'i', 'o', 'u' }; // 정의하는 시점에 정적 변수를 초기화합니다.
};

int main()
{
    std::cout << MyClass::s_mychars.third; // i 출력

    return 0;
}

만약 정적 멤버 변수를 초기화하는 데 코드를 실행해야 한다면 (예: 반복문 사용 등), 조금 복잡하지만 방법이 있습니다. 가장 무난한 방법은 값을 채워주는 함수를 따로 만들어서 객체를 반환하게 한 다음, 그 값을 복사해서 넣는 거예요.

#include <iostream>

struct Chars
{
    char first{};
    char second{};
    char third{};
    char fourth{};
    char fifth{};
};

class MyClass
{
private:
    static Chars generate()
    {
        Chars c{}; // 객체를 하나 만듭니다.
        c.first = 'a'; // 원하는 대로 값을 채워 넣습니다.
        c.second = 'e';
        c.third = 'i';
        c.fourth = 'o';
        c.fifth = 'u';

        return c; // 객체를 반환합니다.
    }

public:
	static inline Chars s_mychars { generate() }; // 반환된 객체의 데이터를 s_mychars에 복사합니다.
};

int main()
{
    std::cout << MyClass::s_mychars.third; // i 출력

    return 0;
}

15.8 — 프렌드(friend) 비멤버 함수

우리는 이전 장부터 계속해서 클래스의 데이터를 보호하는 '접근 제어(public, private)'의 장점에 대해 이야기해 왔습니다. 이 기능은 누가 클래스의 데이터에 접근할 수 있는지를 관리해 주죠. private 데이터는 클래스 내부에서만 건드릴 수 있고, public 데이터는 누구나 접근할 수 있습니다. [14.6 수업 - 접근 함수]에서는 데이터를 private으로 안전하게 숨겨두고, 외부 사람들은 public 함수를 통해서만 접근하게 만드는 방식이 왜 좋은지 배웠습니다.

하지만, 가끔은 이런 룰만으로는 상황을 해결하기 어렵거나 비효율적일 때가 있습니다.

예를 들어, 어떤 데이터를 전문적으로 관리하는 '저장소(storage)' 클래스가 있다고 해볼게요. 그런데 이 데이터를 화면에 멋지게 보여주고 싶어졌습니다. 화면 출력 기능은 옵션도 많고 꽤 복잡한 작업입니다.

물론 데이터 저장 기능과 화면 출력 기능을 하나의 클래스에 다 쑤셔 넣을 수도 있겠지만, 그러면 코드가 너무 복잡하고 지저분해지겠죠. 그래서 저장소 클래스는 '저장'만 하고, 화면 출력 클래스는 '출력'만 하도록 두 개를 분리하는 것이 좋습니다. 역할 분담이 확실해지니까요!
하지만 여기서 문제가 생깁니다. 출력 클래스가 저장소 클래스의 private 데이터에 접근할 수가 없어서, 화면에 데이터를 띄우는 제 역할을 할 수 없게 되어버립니다.

또 다른 경우로, 나중에 '연산자 오버로딩'이라는 것을 배울 때 자주 겪게 될 텐데, 문법적인 이유로 클래스 안에 속한 '멤버 함수'보다 밖에 있는 '비멤버(non-member) 함수'를 쓰는 것이 더 편할 때가 있습니다. 하지만 비멤버 함수 역시 클래스의 private 데이터에는 접근할 수 없다는 똑같은 문제에 부딪히게 됩니다.

만약 우리가 쓰려는 기능에 딱 맞는 public 함수가 이미 만들어져 있다면 정말 다행입니다. 그냥 그걸 쓰면 되니까요. 하지만 그런 함수가 아예 없다면 대체 어떻게 해야 할까요?

그렇다고 다른 클래스나 비멤버 함수가 일을 할 수 있도록 클래스에 새로운 public 함수를 무작정 추가하는 것도 좋은 생각은 아닙니다. 밖에서 함부로 건드리면 안 되는 민감한 정보이거나, 잘못 사용될 위험이 있는 데이터일 수 있으니까요.

우리에게 정말 필요한 것은, "딱 내가 원하는 상대에게만 예외적으로 접근 권한을 주는 방법" 입니다.


Friendship은 마법입니다

이 문제의 완벽한 해결책이 바로 '프렌드(friend)'입니다.

클래스 안에서 friend 키워드를 사용해 선언을 해주면, 컴파일러에게
"이 함수(또는 클래스)는 내 친구야!"라고 알려줄 수 있습니다. C++에서 프렌드(friend) 란 다른 클래스의 private이나 protected 데이터에 자유롭게 접근할 수 있는 특별한 권한을 받은 클래스나 함수를 말합니다. 이 방법을 쓰면, 클래스는 안전함을 유지하면서도 내가 콕 집은 상대에게만 모든 권한을 열어줄 수 있습니다.

핵심 포인트
우정(접근 권한)은 항상 '데이터를 가진 클래스'가 직접 허락해 주는 것입니다. 접근하고 싶은 쪽에서 마음대로 친구가 될 수는 없습니다. 덕분에 클래스는 여전히 "누가 내 데이터에 접근할 수 있는지"를 완벽하게 통제할 수 있죠.

예를 들어, 아까 말한 저장소 클래스가 출력 클래스를 '친구'로 받아준다면, 출력 클래스는 저장소 클래스의 모든 데이터에 마음껏 접근할 수 있게 됩니다. 코드는 깔끔하게 분리되어 있으면서도, 화면 출력이라는 본래 역할을 무사히 해낼 수 있는 것이죠.

참고로 friend 선언은 public이나 private 같은 접근 제어 구역의 영향을 받지 않습니다. 따라서 클래스 내부의 어디에 적어두든 상관없이 똑같이 작동합니다.

프렌드가 무엇인지 알았으니, 이제 비멤버 함수, 멤버 함수, 그리고 다른 클래스에 프렌드 권한을 주는 실제 예시들을 살펴보겠습니다. 이번 수업에서는 프렌드 비멤버 함수 에 대해 알아보고, 다음 [15.9 수업]에서 프렌드 클래스와 프렌드 멤버 함수를 다루겠습니다.


프렌드 비멤버 함수

프렌드 함수 란 마치 그 클래스의 진짜 멤버인 것처럼 privateprotected 데이터에 접근할 수 있는 함수를 말합니다. 그 외의 나머지 특징은 일반 함수와 완전히 똑같습니다.

간단한 클래스가 비멤버 함수를 친구로 만드는 예제를 살펴보겠습니다.

#include <iostream>

class Accumulator
{
private:
    int m_value { 0 };

public:
    void add(int value) { m_value += value; }

    // 비멤버 함수인 void print(const Accumulator& accumulator)를 
    // Accumulator의 친구로 만들어주는 프렌드 선언입니다.
    friend void print(const Accumulator& accumulator);
};

void print(const Accumulator& accumulator)
{
    // print() 함수는 Accumulator의 친구이기 때문에
    // Accumulator의 private 멤버에 자유롭게 접근할 수 있습니다.
    std::cout << accumulator.m_value;
}

int main()
{
    Accumulator acc{};
    acc.add(5); // accumulator에 5를 더합니다.

    print(acc); // print() 비멤버 함수를 호출합니다.

    return 0;
}

이 예제에서 우리는 Accumulator 객체를 받아 작동하는 print() 라는 비멤버 함수를 만들었습니다. print()Accumulator 클래스에 속한 함수가 아니기 때문에 원래대로라면 private 변수인 m_value에 접근할 수 없습니다. 하지만 Accumulator 클래스 안에서 print(const Accumulator& accumulator) 를 친구로 허락해 주었기 때문에, 이제 당당하게 접근할 수 있습니다.

주의할 점은 print() 가 비멤버 함수라는 것입니다. 클래스에 속해 있지 않기 때문에, 어떤 객체의 값을 출력할지 알 수 있도록 반드시 Accumulator 객체를 괄호 안에 직접 넣어주어야 합니다.


클래스 안에서 프렌드 비멤버 함수 정의하기

멤버 함수를 클래스 안에서 작성할 수 있는 것처럼, 프렌드 비멤버 함수도 클래스 내부에서 곧바로 작성(정의)할 수 있습니다. 다음은 Accumulator 클래스 안에서 프렌드 비멤버 함수 print() 를 작성한 예제입니다.

#include <iostream>

class Accumulator
{
private:
    int m_value { 0 };

public:
    void add(int value) { m_value += value; }

    // 클래스 내부에서 정의된 프렌드 함수는 비멤버 함수로 취급됩니다.
    friend void print(const Accumulator& accumulator)
    {
        // print()는 Accumulator의 친구이기 때문에
        // Accumulator의 private 멤버에 접근할 수 있습니다.
        std::cout << accumulator.m_value;
    }
};

int main()
{
    Accumulator acc{};
    acc.add(5); // accumulator에 5를 더합니다.

    print(acc); // print() 비멤버 함수를 호출합니다.

    return 0;
}

함수가 Accumulator 클래스 안에 쏙 들어가 있으니까 마치 멤버 함수 같아 보이시죠? 하지만 속으시면 안 됩니다! friend 로 정의되었기 때문에, 이 함수는 여전히 비멤버 함수 로 취급됩니다 (마치 클래스 밖에 적어둔 것과 똑같이 동작합니다).


문법적으로 프렌드 비멤버 함수가 더 편한 경우

수업 처음에 "멤버 함수보다 비멤버 함수를 쓰는 게 더 편할 때가 있다"고 말씀드렸었죠. 그게 어떤 경우인지 예제로 확인해 봅시다.

#include <iostream>

class Value
{
private:
    int m_value{};

public:
    explicit Value(int v): m_value { v }  { }

    bool isEqualToMember(const Value& v) const;
    friend bool isEqualToNonmember(const Value& v1, const Value& v2);
};

bool Value::isEqualToMember(const Value& v) const
{
    return m_value == v.m_value;
}

bool isEqualToNonmember(const Value& v1, const Value& v2)
{
    return v1.m_value == v2.m_value;
}

int main()
{
    Value v1 { 5 };
    Value v2 { 6 };

    std::cout << v1.isEqualToMember(v2) << '\n';
    std::cout << isEqualToNonmember(v1, v2) << '\n';

    return 0;
}

이 예제에서는 두 Value 객체의 값이 똑같은지 확인하는 두 가지 함수를 만들었습니다. isEqualToMember()는 멤버 함수이고, isEqualToNonmember()는 비멤버 함수입니다. 코드가 어떻게 다른지 집중해서 봐주세요.

멤버 함수인 isEqualToMember()를 쓸 때는 기준이 되는 객체(v1)는 숨겨져 있고, 비교할 객체(v2)만 괄호 안에 넣습니다. 그래서 코드를 읽을 때 "앞의 m_value는 숨겨진 내 거고, 뒤의 v.m_value는 괄호로 들어온 쟤 거구나" 하고 머릿속으로 한 번 정리를 해야 합니다.

반면 비멤버 함수인 isEqualToNonmember()는 두 객체를 모두 괄호 안에 나란히 넣습니다. 함수 안에서도 v1.m_valuev2.m_value 처럼 누구의 값인지 명확하게 이름표가 붙어 있죠. 모양이 좌우 대칭이라 훨씬 읽기 편하고 직관적입니다.

물론 v1.isEqualToMember(v2) 라고 쓰는 게 더 좋다고 생각하실 수도 있습니다. 하지만 나중에 연산자 오버로딩을 배울 때, 왜 비멤버 방식이 중요한지 이 주제가 다시 등장할 것입니다.


여러 클래스의 친구가 되기

하나의 함수가 동시에 여러 클래스의 친구가 될 수도 있습니다. 다음 예제를 볼까요?

#include <iostream>

class Humidity; // Humidity 클래스에 대한 전방 선언(forward declaration)

class Temperature
{
private:
    int m_temp { 0 };

public:
    explicit Temperature(int temp) : m_temp { temp } { }

    // 이 줄이 에러 없이 작동하려면 위에서 전방 선언을 해두어야 합니다.
    friend void printWeather(const Temperature& temperature, const Humidity& humidity); 
};

class Humidity
{
private:
    int m_humidity { 0 };

public:
    explicit Humidity(int humidity) : m_humidity { humidity } {  }

    friend void printWeather(const Temperature& temperature, const Humidity& humidity);
};

void printWeather(const Temperature& temperature, const Humidity& humidity)
{
    std::cout << "온도는 " << temperature.m_temp <<
       " 이고 습도는 " << humidity.m_humidity << " 입니다.\n";
}

int main()
{
    Humidity hum { 10 };
    Temperature temp { 12 };

    printWeather(temp, hum);

    return 0;
}

이 예제에서 주목해야 할 점은 세 가지입니다.

  1. printWeather() 함수는 온도와 습도를 똑같이 중요하게 다룹니다. 따라서 어느 한 클래스에 억지로 집어넣는 것보다, 바깥에 꺼내두는 비멤버 함수 방식이 훨씬 자연스럽습니다.
  2. 이 함수는 온도 클래스와 습도 클래스 모두와 친구를 맺었기 때문에, 양쪽의 private 데이터에 자유롭게 접근할 수 있습니다.
  3. 코드 맨 위에 있는 class Humidity; 라는 줄을 눈여겨보세요. 이것을 전방 선언(Forward declaration) 이라고 부릅니다. 컴파일러에게 "나중에 Humidity라는 클래스가 나올 거야"라고 미리 귀띔해 주는 역할을 합니다. 이 줄이 없다면, 컴파일러는 Temperature 클래스 안에서 Humidity를 만났을 때 "이게 대체 뭐야?" 하고 에러를 뱉어낼 것입니다.

프렌드는 데이터 숨기기(은닉) 원칙을 위반하는 거 아닌가요?

아닙니다. 앞서 말씀드렸듯 프렌드 권한은 데이터를 숨긴 클래스 측에서 "내 친구니까 접근해도 좋아"라고 스스로 허락한 것입니다. 프렌드를 클래스의 능력을 함께 쓰는 '확장팩' 정도로 생각해 보세요. 클래스가 예상하고 허락한 접근이므로 원칙 위반이 아닙니다.

프렌드를 적절히 활용하면, 억지로 코드를 합치지 않아도 되기 때문에 프로그램 설계가 깔끔해지고 유지보수가 훨씬 쉬워집니다. 멤버 함수보다 비멤버 함수를 쓰는 게 구조상 더 알맞을 때도 큰 도움이 되고요.

하지만 주의할 점도 있습니다. 프렌드는 클래스의 속사정(내부 구현)을 훤히 알고 직접 접근하기 때문에, 클래스 내부 코드가 바뀌면 프렌드 함수도 덩달아 수정해야 할 확률이 높습니다. 친구가 너무 많으면 하나를 고칠 때 줄줄이 고쳐야 하는 '도미노 현상'이 일어날 수 있죠.

권장 사항
프렌드 함수를 구현할 때도, 꼭 필요한 경우가 아니라면 직접 데이터에 손대기보다는 클래스에 이미 마련된 public 함수(인터페이스)를 사용하는 것이 좋습니다. 이렇게 하면 나중에 클래스 코드가 바뀌더라도 프렌드 함수까지 뜯어고치는 불상사를 막을 수 있습니다.


프렌드 함수보다는 일반(비프렌드) 함수를 선호하세요

[14.8 수업]에서 우리는 멤버 함수보다는 비멤버 함수를 쓰는 게 좋다고 배웠습니다. 똑같은 이유로, 프렌드 함수보다는 일반(비프렌드) 함수를 쓰는 것이 더 안전하고 좋습니다.

예를 들어, 아래 코드처럼 친구가 데이터에 '직접' 접근하게 놔두면 어떻게 될까요? 나중에 변수 이름(m_value)을 살짝 바꾸기만 해도 print() 함수까지 찾아가서 고쳐야 합니다.

#include <iostream>

class Accumulator
{
private:
    int m_value { 0 }; // 만약 이 이름을 바꾼다면...

public:
    void add(int value) { m_value += value; } // 여기도 수정해야 하고...

    friend void print(const Accumulator& accumulator);
};

void print(const Accumulator& accumulator)
{
    std::cout << accumulator.m_value; // 여기도 수정해야 합니다!
}

int main()
{
    Accumulator acc{};
    acc.add(5); // accumulator에 5를 더합니다.

    print(acc); // print() 비멤버 함수를 호출합니다.

    return 0;
}

이럴 때는 아래처럼 코드를 짜는 것이 훨씬 똑똑한 방법입니다.

#include <iostream>

class Accumulator
{
private:
    int m_value { 0 };

public:
    void add(int value) { m_value += value; }
    int value() const { return m_value; } // 안전하게 값을 읽어오는 접근 함수를 추가했습니다!
};

void print(const Accumulator& accumulator) // 이제 Accumulator의 친구가 아닙니다.
{
    std::cout << accumulator.value(); // 직접 접근하는 대신 접근 함수를 사용합니다.
}

int main()
{
    Accumulator acc{};
    acc.add(5); // accumulator에 5를 더합니다.

    print(acc); // print() 비멤버 함수를 호출합니다.

    return 0;
}

두 번째 예제에서는 print() 함수가 변수를 직접 건드리지 않고, value() 라는 접근 함수를 통해 안전하게 값을 가져옵니다. 이제 Accumulator 내부 구조가 어떻게 바뀌든 print() 함수는 신경 쓸 필요가 전혀 없습니다!

권장 사항
가능하고 합리적인 상황이라면, 함수를 프렌드로 만들기보다는 일반 함수로 구현하는 것을 우선적으로 고려하세요.

다만, 뻔한 기능이라고 해서 기존 클래스에 public 함수를 무작정 자꾸 추가하는 것은 조심해야 합니다. 코드가 지저분해지고 복잡해질 수 있거든요. 위의 Accumulator 예제처럼 누적된 값을 가져오는 단순한 접근 함수를 하나 만드는 것은 아주 훌륭한 선택입니다. 하지만 상황이 너무 복잡해서 새로운 접근 함수를 끝도 없이 만들어야 한다면, 차라리 깔끔하게 프렌드를 쓰는 편이 낫습니다.


15.9 — 프렌드 클래스와 프렌드 멤버 함수

프렌드 클래스

프렌드 클래스(Friend class) 란 다른 클래스의 숨겨진 정보(private이나 protected 멤버)에 자유롭게 접근할 수 있는 특별한 클래스입니다. 진짜 친한 친구끼리 비밀을 공유하는 것과 같다고 생각하면 이해하기 쉬울 거예요!

예제를 한번 볼까요?

#include <iostream>

class Storage
{
private:
    int m_nValue {};
    double m_dValue {};

public:
    Storage(int nValue, double dValue)
       : m_nValue { nValue }, m_dValue { dValue }
    { }

    // Display 클래스를 Storage의 프렌드(친구)로 만듭니다.
    friend class Display;
};

class Display
{
private:
    bool m_displayIntFirst {};

public:
    Display(bool displayIntFirst)
         : m_displayIntFirst { displayIntFirst }
    {
    }

    // Display가 Storage의 프렌드이기 때문에, Display는 Storage의 숨겨진(private) 멤버에 접근할 수 있습니다.
    void displayStorage(const Storage& storage)
    {
        if (m_displayIntFirst)
            std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
        else // double 값을 먼저 출력합니다.
            std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
    }

    void setDisplayIntFirst(bool b)
    {
         m_displayIntFirst = b;
    }
};

int main()
{
    Storage storage { 5, 6.7 };
    Display display { false };

    display.displayStorage(storage);

    display.setDisplayIntFirst(true);
    display.displayStorage(storage);

    return 0;
}

Display 클래스가 Storage의 친구(프렌드)이기 때문에, Display는 자기가 가진 Storage 객체의 숨겨진 정보에 얼마든지 접근할 수 있습니다.
이 프로그램을 실행하면 다음과 같은 결과가 나옵니다.

6.7 5
5 6.7

프렌드 클래스에 대해 몇 가지 더 알아두면 좋은 점이 있어요.

첫째, DisplayStorage의 친구라고 해도, Storage 객체의 *this 포인터(자기 자신을 가리키는 포인터)에는 접근할 수 없어요. (*this는 사실 함수 매개변수처럼 숨겨져 전달되는 것이기 때문이죠.)

둘째, 프로그래밍 세계에서 우정은 일방통행 일 수 있습니다! DisplayStorage의 친구라고 해서, 반대로 Storage도 자동으로 Display의 친구가 되는 건 아니에요. 서로 친구가 되려면 양쪽 모두 서로를 프렌드로 선언해야 합니다. (글쓴이의 말: 현실의 짝사랑 같아서 조금 슬프게 들렸다면 미안해요!)

셋째, 우정은 건너뛰지 않습니다. A가 B의 친구이고, B가 C의 친구라고 해서 A와 C가 자동으로 친구가 되는 건 아니에요.

심화 내용:
우정은 상속되지도 않습니다. A가 B를 친구로 만들었다고 해서, B의 자식 클래스들까지 자동으로 A의 친구가 되지는 않아요.

프렌드 클래스를 선언하는 것은 컴파일러에게 "이런 클래스가 나중에 나올 거야"라고 미리 알려주는 전방 선언(forward declaration) 역할도 같이 합니다. 그래서 따로 미리 선언해둘 필요가 없어요. 위 예제에서 friend class Display;라고 쓴 것은 "Display를 내 친구로 할게"라는 뜻인 동시에 "Display라는 클래스가 존재해"라고 미리 알려주는 역할을 완벽하게 해냅니다.


프렌드 멤버 함수

클래스 전체를 통째로 친구로 만드는 대신, 특정 함수 딱 하나만 친구로 만들 수도 있습니다. 일반 함수를 친구로 만들 때와 방법은 비슷하지만, 대신 그 멤버 함수의 이름을 정확히 적어주면 됩니다.

하지만 막상 해보면 생각보다 조금 까다로울 수 있어요. 이전 예제를 조금 바꿔서 Display::displayStorage 함수 딱 하나만 프렌드 멤버 함수로 만들어 볼게요. 아마 처음에는 아래처럼 시도해 볼 겁니다.

#include <iostream>

class Display; // Display 클래스에 대한 전방 선언 (미리 알려줌)

class Storage
{
private:
	int m_nValue {};
	double m_dValue {};

public:
	Storage(int nValue, double dValue)
		: m_nValue { nValue }, m_dValue { dValue }
	{
	}

	// Display::displayStorage 멤버 함수를 Storage 클래스의 프렌드로 만듭니다.
	friend void Display::displayStorage(const Storage& storage); // 에러: Storage는 아직 Display 클래스의 전체 모습을 보지 못했습니다.
};

class Display
{
private:
	bool m_displayIntFirst {};

public:
	Display(bool displayIntFirst)
		: m_displayIntFirst { displayIntFirst }
	{
	}

	void displayStorage(const Storage& storage)
	{
		if (m_displayIntFirst)
			std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
		else // double 값을 먼저 출력합니다.
			std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
	}
};

int main()
{
    Storage storage { 5, 6.7 };
    Display display { false };
    display.displayStorage(storage);

    return 0;
}

하지만 안타깝게도 이 코드는 작동하지 않습니다. 특정 멤버 함수 하나만 친구로 만들려면, 컴파일러는 단순히 "이름만 미리 아는 것(전방 선언)"으로는 부족하고, 그 함수가 속한 클래스의 전체 모습(전체 정의) 을 꼼꼼히 확인해야 하거든요. 위 코드에서는 StorageDisplay의 전체 모습을 아직 모르는 상태라서 에러가 발생합니다.

다행히 해결책은 쉽습니다! Display 클래스의 코드를 Storage 클래스보다 위로 올려주면 됩니다.

#include <iostream>

class Display
{
private:
	bool m_displayIntFirst {};

public:
	Display(bool displayIntFirst)
		: m_displayIntFirst { displayIntFirst }
	{
	}

	void displayStorage(const Storage& storage) // 컴파일 에러: 컴파일러는 Storage가 무엇인지 모릅니다.
	{
		if (m_displayIntFirst)
			std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
		else // double 값을 먼저 출력합니다.
			std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
	}
};

class Storage
{
private:
	int m_nValue {};
	double m_dValue {};

public:
	Storage(int nValue, double dValue)
		: m_nValue { nValue }, m_dValue { dValue }
	{
	}

	// Display::displayStorage 멤버 함수를 Storage 클래스의 프렌드로 만듭니다.
	friend void Display::displayStorage(const Storage& storage); // 이제 문제없습니다.
};

int main()
{
    Storage storage { 5, 6.7 };
    Display display { false };
    display.displayStorage(storage);

    return 0;
}

앗, 그런데 이번엔 다른 문제가 생겼네요. Display를 위로 올렸더니, 그 안에 있는 displayStorage() 함수가 사용하는 매개변수 Storage를 컴파일러가 못 알아보게 된 거예요. 순서를 다시 원상복구하면 아까 문제가 다시 생기니 그럴 수도 없고 난감하죠.

하지만 걱정 마세요! 이 문제도 간단한 두 단계를 거쳐 해결할 수 있습니다.

  1. 먼저 class Storage;라고 전방 선언 을 해줍니다. 그러면 컴파일러가 "아, Storage라는 게 나중에 나오는구나" 하고 안심합니다.
  2. 그 다음, Display::displayStorage() 함수의 진짜 알맹이(정의 부분)를 클래스 밖으로 빼서, Storage 클래스의 코드가 다 끝난 맨 밑으로 옮겨줍니다.

코드로 보면 다음과 같습니다:

#include <iostream>

class Storage; // Storage 클래스에 대한 전방 선언

class Display
{
private:
	bool m_displayIntFirst {};

public:
	Display(bool displayIntFirst)
		: m_displayIntFirst { displayIntFirst }
	{
	}

	void displayStorage(const Storage& storage); // 여기서 참조(reference)를 사용하기 위해 Storage에 대한 전방 선언이 필요합니다.
};

class Storage // Storage 클래스의 전체 정의
{
private:
	int m_nValue {};
	double m_dValue {};

public:
	Storage(int nValue, double dValue)
		: m_nValue { nValue }, m_dValue { dValue }
	{
	}

	// Display::displayStorage 멤버 함수를 Storage 클래스의 프렌드로 만듭니다.
	// (displayStorage가 멤버이므로) Display 클래스의 전체 정의를 보아야 합니다.
	friend void Display::displayStorage(const Storage& storage);
};

// 이제 Display::displayStorage를 정의할 수 있습니다.
// (우리가 Storage의 멤버에 접근하므로) Storage 클래스의 전체 정의를 보아야 합니다.
void Display::displayStorage(const Storage& storage)
{
	if (m_displayIntFirst)
		std::cout << storage.m_nValue << ' ' << storage.m_dValue << '\n';
	else // double 값을 먼저 출력합니다.
		std::cout << storage.m_dValue << ' ' << storage.m_nValue << '\n';
}

int main()
{
    Storage storage { 5, 6.7 };
    Display display { false };
    display.displayStorage(storage);

    return 0;
}

드디어 모든 것이 완벽하게 작동합니다!

  • 맨 위의 Storage 전방 선언 덕분에 Display 안에서 함수를 문제없이 선언할 수 있었습니다.
  • Display의 전체 코드가 먼저 나왔기 때문에, Storage 안에서 해당 함수를 친구로 지정할 수 있었습니다.
  • 마지막으로 Storage 전체 코드가 나온 뒤에 함수의 알맹이를 작성했기 때문에, 함수 안에서 Storage의 숨겨진 정보들에 제대로 접근할 수 있게 된 거죠.

조금 복잡하게 느껴진다면 위 코드의 주석들을 천천히 다시 읽어보세요. 핵심은 단순히 이름만 언급할 때는 '전방 선언'만으로 충분하지만, 그 클래스 안의 구체적인 내용(멤버)에 접근하려면 반드시 '클래스 전체 정의'를 컴파일러가 미리 봐야 한다 는 것입니다.

"이거 순서 맞추기 너무 골치 아픈데요?" 라고 생각하셨나요? 맞아요, 골치 아픈 게 정상입니다! 다행히 이런 복잡한 춤을 추는 이유는 우리가 모든 코드를 하나의 파일 안에 다 넣으려고 했기 때문이에요.

가장 좋은 해결책은 각 클래스의 틀을 헤더 파일(.h) 에 따로따로 나누어 담고, 실제 함수의 내용들은 각각의 .cpp 파일 에 따로 모아두는 것입니다. 그렇게 파일들을 깔끔하게 나누면 이런 복잡한 순서 고민을 전혀 할 필요가 없답니다!


15.10 — 참조 한정자

작성자의 메모
이번 레슨이 '선택 사항'이라는 점을 알려드립니다. 가볍게 쭉 읽어보면서 이런 개념이 있구나 하고 익숙해지는 것을 추천하지만, 앞으로 나올 다른 레슨들을 배우기 위해 여기서 완벽하게 다 이해해야 할 필요는 없습니다.

이전 레슨인 '14.7 - 데이터 멤버에 대한 참조를 반환하는 멤버 함수'에서, 클래스 안의 데이터를 '참조(reference)'로 반환하는 함수를 쓸 때 주의할 점을 배웠습니다. 함수를 호출하는 본체(암시적 객체)가 잠깐 생겼다 사라지는 임시 객체인 우측값(rvalue) 이라면 이 방식이 아주 위험해질 수 있다고 했었죠. 간단히 다시 복습해 볼까요?

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
	std::string m_name{};

public:
	Employee(std::string_view name)
		: m_name { name }
	{}

	const std::string& getName() const { return m_name; } // getter는 const 참조로 반환합니다.
};

// createEmployee() 함수는 Employee 객체를 '값'으로 반환합니다 (즉, 반환된 값은 우측값/임시 객체입니다)
Employee createEmployee(std::string_view name)
{
	Employee e { name };
	return e;
}

int main()
{
	// 케이스 1: 괜찮음: 우측값 클래스 객체의 멤버에 대한 반환된 참조를 같은 줄(표현식) 안에서 바로 사용합니다.
	std::cout << createEmployee("Frank").getName() << '\n';

	// 케이스 2: 나쁨: 우측값 클래스 객체의 멤버에 대한 반환된 참조를 나중에 쓰려고 변수에 저장합니다.
	const std::string& ref { createEmployee("Garbo").getName() }; // createEmployee()의 반환값이 파괴될 때 참조(ref)는 허공을 가리키는 댕글링(dangling) 참조가 됩니다.
	std::cout << ref << '\n'; // 정의되지 않은 동작 (미정의 동작)

	return 0;
}

케이스 2를 보면, createEmployee("Garbo") 가 만들어낸 우측값(임시 객체)은 ref 변수를 초기화한 직후에 바로 메모리에서 파괴되어 버립니다. 그래서 ref 는 방금 파괴되어서 사라진 데이터를 멍하니 가리키고 있게 되죠. 그 이후에 ref 를 사용하려고 하면 프로그램이 꼬여버리는 미정의 동작(undefined behavior) 이 발생합니다.

여기서 약간의 딜레마가 생깁니다.

만약 getName() 함수를 값으로 반환(return by value) 하게 만들면, 객체가 잠깐 나타났다 사라지는 우측값(rvalue) 일 때는 안전합니다. 하지만 가장 흔한 상황인 일반 변수, 즉 좌측값(lvalue) 일 때는 굳이 필요 없는 무거운 복사 작업이 일어나게 됩니다.

반대로 getName() 함수를 const 참조로 반환(return by const reference) 하게 만들면, 문자열을 복사할 필요가 없어서 아주 효율적입니다. 하지만 객체가 임시로 쓰이는 우측값(rvalue) 일 때 잘못 사용하면 위에서 본 것처럼 치명적인 미정의 동작을 일으킬 수 있죠.

보통 이런 멤버 함수들은 일반적인 변수 형태인 좌측값 객체에서 훨씬 더 자주 호출됩니다. 그래서 C++에서는 일반적으로 'const 참조로 반환'하는 방식을 선택하고, 대신 우측값(임시 객체)일 때는 참조를 변수에 저장해서 잘못 쓰는 일이 없도록 프로그래머가 스스로 조심하는 방법을 관행적으로 사용합니다.


참조 한정자

위에서 살펴본 문제의 근본적인 원인은, 하나의 함수(getName)가 두 가지 다른 상황(좌측값일 때와 우측값일 때)을 모두 완벽하게 처리해야 한다는 점입니다. 한 상황에 가장 좋은 방법이 다른 상황에서는 별로 좋지 않으니까요.

이 문제를 해결하기 위해 C++11에서는 참조 한정자(ref-qualifier) 라는 잘 알려지지 않은 멋진 기능을 도입했습니다. 이 기능을 사용하면 함수를 호출하는 객체가 좌측값인지 우측값인지에 따라 함수를 오버로딩(따로 정의)할 수 있습니다. 즉, 좌측값을 위한 getName() 과 우측값을 위한 getName() 두 가지 버전을 따로 만들 수 있는 것이죠!

먼저, 참조 한정자가 없는 원래의 getName() 함수를 보겠습니다.

const std::string& getName() const { return m_name; } // 좌측값과 우측값 암시적 객체 모두에서 호출 가능합니다.

이 함수에 참조 한정자를 붙이려면, 좌측값 전용 함수에는 뒤에 & 기호를 붙이고, 우측값 전용 함수에는 뒤에 && 기호를 붙여주면 됩니다.

const std::string& getName() const &  { return m_name; } // & 한정자는 좌측값 객체일 때만 이 함수를 실행하게 하며, 참조로 반환합니다.
std::string        getName() const && { return m_name; } // && 한정자는 우측값 객체일 때만 이 함수를 실행하게 하며, 값으로 복사해서 반환합니다.

이 두 함수는 엄연히 다른 오버로딩 함수이기 때문에, 반환 타입(return type) 도 다르게 설정할 수 있습니다! 좌측값용 함수는 효율성을 위해 'const 참조'로 반환하고, 우측값용 함수는 안전성을 위해 '값'으로 반환하도록 만든 것입니다.

아래는 전체 적용 예시입니다.

#include <iostream>
#include <string>
#include <string_view>

class Employee
{
private:
	std::string m_name{};

public:
	Employee(std::string_view name)
		: m_name { name }
	{}

	const std::string& getName() const &  { return m_name; } // & 한정자: 함수를 좌측값 객체에서만 실행하도록 제한 (참조 반환)
	std::string        getName() const && { return m_name; } // && 한정자: 함수를 우측값 객체에서만 실행하도록 제한 (값 반환)
};

// createEmployee()는 Employee를 값으로 반환합니다 (즉, 반환된 값은 우측값입니다)
Employee createEmployee(std::string_view name)
{
	Employee e { name };
	return e;
}

int main()
{
	Employee joe { "Joe" };
	std::cout << joe.getName() << '\n'; // Joe는 좌측값이므로, std::string& getName() & 버전이 호출됩니다 (참조 반환)

	std::cout << createEmployee("Frank").getName() << '\n'; // Frank는 우측값(임시 객체)이므로, std::string getName() && 버전이 호출됩니다 (복사본 반환)

	return 0;
}

이렇게 하면 좌측값일 때는 성능(속도)을 챙길 수 있고, 우측값일 때는 안전성을 챙길 수 있습니다.


심화 학습

위에서 만든 우측값용 getName() 함수는 복사가 일어나기 때문에 성능 면에서 약간 아쉬울 수 있습니다. 어차피 임시 객체(const가 아닌 우측값)는 이 코드가 끝나면 사라질 텐데, 굳이 무겁게 복사할 필요가 있을까요? 그래서 단순히 복사하는 대신, 멤버를 이동(move) 시키도록 std::move 를 사용할 수도 있습니다.

이를 위해 const가 아닌 우측값을 위한 getter 함수를 아래처럼 하나 더 추가할 수 있습니다.

// 객체가 const가 아닌 우측값이라면, std::move를 사용해서 m_name을 이동(move)시킵니다.
std::string getName() && { return std::move(m_name); }

이 함수는 기존의 const 우측값 getter와 함께 쓸 수도 있고, 그냥 이 함수만 단독으로 쓸 수도 있습니다 (const 우측값은 현실에서 거의 쓰이지 않기 때문입니다). std::move 에 대해서는 나중에 레슨 22.4에서 자세히 다룰 예정입니다.


참조 한정 멤버 함수에 대한 몇 가지 참고 사항

첫째, 하나의 함수 이름에 대해 참조 한정자가 없는 함수참조 한정자가 있는 함수 를 섞어서 같이 쓸 수는 없습니다. 둘 중 하나의 방식만 선택해야 합니다.

둘째, C++의 일반적인 문법에서 const 좌측값 참조가 우측값을 받아줄 수 있는 것처럼, 만약 const & (const 좌측값 한정) 함수 하나만 만들어두면, 좌측값이든 우측값이든 가리지 않고 다 이 함수를 호출할 수 있습니다.

셋째, = delete 키워드를 사용해서 특정 한정자 함수를 강제로 삭제(사용 금지)할 수도 있습니다. 예를 들어, && (우측값 한정) 버전을 지워버리면, 우측값(임시 객체)으로는 아예 이 함수를 호출하지 못하도록 원천 차단할 수 있습니다.

그럼 왜 참조 한정자 사용을 추천하지 않을까요?

참조 한정자는 꽤 깔끔한 기능 같아 보이지만, 이런 식으로 사용하는 데에는 몇 가지 뚜렷한 단점이 있습니다.

  • 참조를 반환하는 모든 getter 함수마다 일일이 우측값용 오버로딩을 추가하면 클래스 코드가 너무 지저분해집니다. 별로 자주 일어나지도 않고 좋은 코딩 습관으로 충분히 막을 수 있는 문제를 예방하겠다고 말이죠.
  • 우측값 함수를 값 반환으로 만들면, 굳이 복사나 이동을 하지 않아도 안전했던 상황(예: 맨 위 코드의 '케이스 1'처럼 같은 줄에서 한 번 출력하고 마는 경우)에서도 쓸데없이 성능 비용(복사 비용)을 지불해야 합니다.

게다가 이런 이유도 있습니다.

  • 대부분의 C++ 개발자들이 이 기능의 존재 자체를 잘 모릅니다 (그래서 오해를 사거나 비효율적으로 사용될 수 있습니다).
  • C++ 표준 라이브러리(Standard Library)도 이 기능을 거의 사용하지 않습니다.

이러한 모든 이유로 인해, 저희는 참조 한정자를 모범 사례(best practice) 로 추천하지 않습니다. 대신, 데이터를 얻어오는 접근 함수(getter)가 반환한 결과는 그 자리에서 바로바로 사용하고, 반환된 참조를 변수에 저장해두고 나중에 또 쓰려는 행동은 피하는 것 이 가장 좋습니다.


이번 15장 요약본도 초보자분들이 이해하기 쉽도록, 복잡한 전문 용어를 최대한 부드럽게 풀어서 번역해 드리겠습니다. 어려운 C++ 문법을 배우시느라 정말 고생 많으셨어요! 이전과 동일하게 굵은 글씨 띄어쓰기 규칙도 완벽하게 적용했습니다.


15.x — 15장 요약 및 퀴즈

  • 모든 (정적(static)이 아닌) 멤버 함수 안에는 this 라는 특별한 키워드가 숨어 있습니다. 이것은 현재 작업 중인 객체의 메모리 주소를 기억하는 변하지 않는 포인터(const pointer)입니다. 함수가 *this 를 참조(reference)로 반환하게 만들면, 한 줄의 코드에서 여러 개의 멤버 함수를 기차처럼 이어서 호출하는 메서드 체이닝(method chaining) 이 가능해집니다.
  • 클래스를 정의할 때는 클래스 이름과 똑같은 이름의 헤더 파일(.h) 안에 넣는 것을 추천합니다. 접근 함수(getter/setter)나 내용이 텅 빈 생성자처럼 아주 단순한 멤버 함수들은 클래스 정의 안에 직접 적어 넣어도 괜찮습니다.
  • 반면에 복잡하고 내용이 긴 멤버 함수들은 클래스와 이름이 같은 소스 파일(.cpp)에 따로 빼서 작성하는 것이 좋습니다.
  • 클래스 안에서 또 다른 '타입(type)'을 만드는 것을 중첩 타입(nested type) 또는 멤버 타입(member type) 이라고 부릅니다. 타입의 별명을 지어주는 구문(타입 별칭)도 클래스 안에 중첩해서 쓸 수 있습니다.
  • 클래스 템플릿(class template) 안에서 만들어진 멤버 함수는 그 클래스의 템플릿 매개변수를 그대로 쓸 수 있습니다. 하지만 클래스 템플릿 바깥에서 멤버 함수를 따로 정의하려면, 템플릿 매개변수를 다시 한 번 명시해 주어야 합니다. 그리고 이 함수들은 클래스 템플릿 정의 바로 아래에 (같은 파일 안에서) 작성해야 합니다.
  • 정적 멤버 변수(Static member variables) 는 프로그램이 끝날 때까지 살아있으며, 해당 클래스로 만든 모든 객체들이 똑같이 '공유'하는 변수입니다. 이 변수들은 객체를 단 하나도 만들지 않았을 때조차도 이미 존재하고 있습니다. 이 변수들을 사용할 때는 클래스이름::멤버이름 처럼 범위 지정 연산자(::)를 사용해서 접근하는 것이 좋습니다.
  • 정적 멤버를 inline 으로 만들면, 굳이 밖으로 빼지 않고 클래스를 정의하는 중괄호 안에서 곧바로 초기화(기본값 설정)를 할 수 있습니다.
  • 정적 멤버 함수(Static member functions) 는 객체를 굳이 만들지 않아도 바로 호출할 수 있는 함수입니다. 이 함수들 안에는 특정 객체를 가리키는 this 포인터가 없기 때문에, 객체마다 다르게 가지는 일반(비정적) 데이터 멤버는 사용할 수 없습니다. 오직 정적 데이터만 사용할 수 있습니다.
  • 클래스 안에서 friend 키워드를 사용하면, 다른 특정 클래스나 함수를 "내 친구야!"라고 컴파일러에게 알려줄 수 있습니다. 프렌드(friend) 가 되면, 원래는 꽁꽁 숨겨져 있어서 밖에서 볼 수 없는 남의 클래스의 은닉된 정보(private, protected 멤버)에 마음껏 접근할 수 있는 특별한 권한을 얻습니다.
  • 프렌드 함수(friend function) 는 마치 자신이 그 클래스의 식구인 것처럼 은닉된 정보에 접근할 수 있는 함수입니다 (일반 함수이든 다른 클래스의 멤버 함수이든 상관없습니다).
  • 프렌드 클래스(friend class) 는 다른 클래스의 은닉된 정보에 자유롭게 접근할 수 있는 통째로 친구 맺은 클래스를 말합니다.
profile
안녕하세요.

0개의 댓글