CodeGym /행동 /C++ SELF /auto& / const auto& — const를 잃지 않는 방법

auto& / const auto& — const를 잃지 않는 방법

C++ SELF
레벨 56, 레슨 3
사용 가능

1. auto는 편리하지만 때로는 불필요한 복사를 한다

프로그래밍을 막 시작할 때는 복사가 무해해 보입니다: int를 복사했다고 해서 별일 아니야라고 생각하죠. 하지만 오른쪽 표현식에 std::string, std::vector 또는 필드가 많은 당신의 struct가 나타나면, "편한 한 줄짜리 auto"가 값비싼 복사를 조용히 만들 수 있고, 당신은 눈치채지 못할 수 있습니다. 이는 "그냥 물 한 컵"을 가져가려다 물통 전체를 들고 오는 것과 비슷합니다.

이 강의에서는 컴파일러에게 단순하고 정직한 질문을 던지는 법을 배우겠습니다: "나는 복사가 필요한가 아니면 참조가 필요한가?" — 그리고 그 질문에 대해 auto& 와 const auto& 로 답하는 법을 배웁니다.

auto가 & 없이 할 일과 왜 const가 사라지는가

변수 선언에서 일반적인 auto는 의미상 "새 변수를 만들고 오른쪽 값을 그 안에 넣어라"와 비슷합니다. 오른쪽이 const여도, 왼쪽은 종종 일반적인 변경 가능한 타입이 됩니다. 이것은 "해를 끼치는 마법"이 아니라 언어의 논리입니다: 새 변수를 만들면 그것은 별도의 객체로서 자신의 생명 주기와 규칙을 갖습니다.

복사와 const 상실이 드러나는 짧은 예제를 보겠습니다:

#include <iostream>
#include <string>

int main() {
    const std::string s = "hello";
    auto x = s;              // std::string (복사), const가 제거됨

    x[0] = 'H';
    std::cout << s << '\n';  // hello
    std::cout << x << '\n';  // Hello
}

계약 관점에서는 정상입니다: x는 s의 "두 번째 이름"이 아니라 별도의 문자열입니다. 하지만 복사하지 않고 const를 유지하길 원했다면, 단순한 auto는 적절한 도구가 아닙니다.

2. auto&: 복사 대신 참조

auto&는 "타입을 유도하되 변수를 참조로 만들어라"는 방법입니다. 즉 복사본을 만들지 않고 이미 존재하는 객체에 바인딩합니다. 오른쪽이 참조를 반환할 때(예: 벡터의 요소)나 객체를 "제자리에서" 수정하고 싶을 때 특히 유용합니다.

동작을 체감하는 것이 중요합니다: auto&는 문자 수를 줄이기 위한 것이 아니라 계약에 관한 것입니다. 코드 독자(그리고 컴파일러)에게 "나는 원본과 함께 작업하고 있으며, 변경은 실제로 일어날 것이다"라고 알리는 셈입니다.

std::vector 요소와 함께하는 작은 예제:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    auto& first = v[0];   // int&
    first = 99;

    std::cout << v[0] << '\n'; // 99
}

핵심 아이디어는: first는 "첫 번째 요소의 값"이 아니라 정말 첫 번째 요소 자체입니다. 따라서 할당은 컨테이너를 변경합니다.

또 한 가지 중요한 점: 비상수 참조(auto&)는 변경할 수 없는 것에 바인딩될 수 없습니다. 컴파일러는 계약 위반으로부터 당신을 문자 그대로 보호합니다.

#include <string>

int main() {
    const std::string name = "Alice";
    // auto& ref = name; // 오류: const인 것에 대해 비상수 참조를 얻을 수 없음
}

auto&는 "나는 변경할 수 있다"를 뜻하고, const는 "변경 불가"를 뜻합니다. 이것은 충돌이며 컴파일러는 정직하게 허용하지 않습니다.

4. const auto&: 복사 없이 읽고 계약을 지키기

const auto&는 현대 C++에서 데이터를 읽을 때 매우 실용적인 패턴 중 하나입니다. 이는 "타입을 유도하되 참조를 잡고, 그 참조를 통해 변경을 금지"를 의미합니다. 즉 복사 없이 원본에 접근하지만 그걸 통해 실수로 변경할 수는 없습니다.

컨테이너를 순회할 때 특히 편리합니다: 요소를 확인하고 출력하고 계산하는 등 각 요소를 복사하지 않고 처리하고 싶을 때 유용합니다.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> words{"cat", "elephant", "dog"};

    for (const auto& w : words) {
        std::cout << w << '\n'; // cat / elephant / dog
    }
}

여기서 for (auto w : words)였다면, 각 문자열이 w로 복사되었을 것입니다. 짧은 문자열에서는 때때로 괜찮을 수 있지만, "기본값으로 복사"하는 습관은 시간이 지나면서 비용이 커집니다.

또 하나의 좋은 점: const auto&는 임시 객체(예: 함수 결과)에 바인딩될 수 있고, 그 임시 객체의 수명을 참조의 수명까지 연장합니다. 약간 무서울 수 있지만 실제로는 자주 안전하게 사용됩니다: "결과를 얻고 불필요한 복사를 하지 않는다".

5. 요약표: auto vs auto& vs const auto&

망설여질 때는 간단한 표를 참고하는 것이 유용합니다. 이해를 대체할 수는 없지만, 특히 초보 시절에는 "두뇌용 요약표"로 훌륭히 작동합니다.

표기 왼쪽에서 생성되는 것 변수를 통해 변경 가능한가? 복사가 발생하는가? 전형적 의미
auto x = expr;
일반 변수 예 예 "나는 별도의 복사가 필요하다"
auto& x = expr;
참조 예 (오른쪽이 const가 아니면) 아니오 "나는 원본으로 작업하고 변경할 것이다"
const auto& x = expr;
const-참조 아니오 아니오 "나는 복사 없이 원본을 읽는다"
const auto x = expr;
const 변수 아니오 예 "나는 복사된 불변값이 필요하다"

마지막 줄에 주목하세요: const auto x는 const auto& x와 동일하지 않습니다. 전자는 여전히 복사입니다. 단지 그 복사가 "잠금 상태"라는 차이일 뿐입니다.

6. 실전 예: "TaskBox"와 안전한 반복

추상으로만 남기지 않기 위해 과정 스타일을 유지하겠습니다: 작은 코드 조각을 작성해 콘솔 앱으로 이어갑니다. 간단한 작업 관리자 TaskBox가 있다고 가정해 보겠습니다: 작업 목록을 저장하고 출력하며 완료 표시를 허용합니다. "완벽한 아키텍처"를 만드는 것이 목적이 아니라, auto&와 const auto&가 불필요한 복사와 우발적 변경을 실제로 어디에서 막아주는지 보는 것이 중요합니다.

모델 Task: 간단한 작업 구조체

데이터 모델부터 시작합니다. 작업은 id, 제목, 완료 플래그로 이루어져 있다고 합시다. std::string이 내부에 있기 때문에 이미 꽤 "무거운" 객체입니다 — 단순한 int가 아닙니다. 복사는 초보자들이 생각하는 것보다 비용이 클 수 있습니다.

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

이 구조체는 std::vector<Task>에 보관될 것입니다. 그리고 그것을 순회하기 시작하면, 우리는 "읽는가?" 아니면 "수정하는가?"라는 질문에 직면하게 됩니다. 바로 그 지점에서 auto, auto&, 그리고 const auto& 사이의 선택이 계속 등장합니다.

작업 목록 출력: 왜 const auto&가 필요한가

출력은 전형적인 "나는 읽기만 한다"의 예입니다. 요소를 변경할 필요가 없습니다. 따라서 우리는 두 가지 효과를 원합니다: Task를 복사하지 않고, 루프 내에서 실수로 done이나 title을 변경하지 못하게 하는 것.

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (const auto& t : tasks) {
        std::cout << t.id << ": " << t.title << " [" << (t.done ? "x" : " ") << "]\n";
        // 1: Buy milk [ ]
    }
}

여기서 주목할 점: t는 참조이므로 복사가 없습니다. 그리고 const이므로 루프 안에서 t.done = true; 와 같이 쓰면 컴파일러가 박물관의 경비원처럼 막아줍니다: "전시품을 손으로 만지지 마시오".

id로 작업 찾기: 눈치채지 못하고 비싼 실수를 하는 방법

이제 id로 작업을 찾는 함수를 만들어봅시다. 초보자는 "모든 작업을 순회하며 비교하겠다"고 자주 씁니다. 그것은 괜찮지만, 중요한 부분은 "어떻게 순회하느냐"입니다.

보기에는 무해해 보이지만 복사를 만드는 변형:

#include <vector>

bool has_task_copying(const std::vector<Task>& tasks, int id) {
    for (auto t : tasks) {          // 각 Task의 복사!
        if (t.id == id) return true;
    }
    return false;
}

작업이 10개라면 눈치채지 못합니다. 작업이 10,000개이고 title에 긴 문자열이 들어 있다면, "단지 확인만 하려고" CPU를 갑자기 혹사시키게 됩니다.

읽기를 위한 올바른 버전:

#include <vector>

bool has_task(const std::vector<Task>& tasks, int id) {
    for (const auto& t : tasks) {   // 복사 없음
        if (t.id == id) return true;
    }
    return false;
}

코드는 시각적으로 거의 바뀌지 않았지만 의미는 달라졌습니다: 이제 진정한 "조회"이며, "10,000번 복사해서 그다음에 확인"하는 것이 아닙니다.

작업을 완료로 표시하기: 언제 auto&가 필요한가

이제 작업을 변경하는 동작, 즉 "완료로 표시"를 만들어봅시다. 여기서는 더 이상 const auto&를 사용할 수 없습니다, 왜냐하면 벡터의 요소를 변경해야 하기 때문입니다. 따라서 선택은 auto&입니다.

#include <vector>

bool mark_done(std::vector<Task>& tasks, int id) {
    for (auto& t : tasks) {        // 요소에 대한 참조, 변경 가능
        if (t.id == id) {
            t.done = true;
            return true;
        }
    }
    return false;
}

만약 실수로 for (auto t : tasks) (참조 없음)라고 작성하면, 당신은 복사본인 t를 변경하는 것이고, 벡터 자체는 그대로 남습니다. 초보자 코드에서 가장 흔한 버그 중 하나입니다: "t.done = true를 썼는데 왜 동작하지 않지?" — 그건 표식이 복사본에 달렸기 때문입니다.

7. 유용한 뉘앙스

왜 auto&가 함수 결과에 바인딩되지 않는가

가끔 이렇게 하고 싶습니다: "함수를 호출해서 결과를 얻고 참조를 바인딩해서 복사하지 않겠다". 여기서 함정이 있습니다: 비상수 참조(auto&)는 임시 객체에 바인딩될 수 없습니다. 이는 곧 사라질 객체를 수정하려는 이상한 상황을 방지하기 위한 보호장치입니다.

예제를 봅시다:

#include <string>

std::string make_title() {
    return "Read a book";
}

int main() {
    // auto& r = make_title(); // 오류: 임시 값에 auto&를 바인딩할 수 없음
}

컴파일러는 마치 "변경 가능한 참조를 얻고 싶은데 객체는 임시이고 곧 사라지려 한다"라고 말하는 셈입니다.

결과를 복사하지 않고(또는 불필요한 중간 단계를 줄이면서) 읽고 싶다면 보통 const auto&가 적합합니다:

#include <iostream>
#include <string>

std::string make_title() {
    return "Read a book";
}

int main() {
    const auto& title = make_title();
    std::cout << title << '\n'; // Read a book
}

이는 "임시 객체에 대한 참조"처럼 보이지만, 실제로는 const& 덕분에 임시 객체의 수명이 참조가 살아있는 블록 끝까지 안전하게 연장됩니다. 읽기 용도로는 잘 작동하며 자주 사용됩니다.

const auto x vs const auto& x: 둘은 다른 "변경 금지"

겉보기엔 둘 다 "const라서 변경 불가"처럼 보입니다. 하지만 의미는 다릅니다: 하나는 "복사를 만들고 그 복사를 변경 금지"하고, 다른 하나는 "복사를 만들지 않고 원본을 참조하되 그 참조를 통해 변경 금지"합니다.

문자열 예제로 비교해봅시다:

#include <iostream>
#include <string>

int main() {
    std::string s = "hello";

    const auto a = s;      // const std::string (복사)
    const auto& b = s;     // const std::string& (참조)

    s[0] = 'H';

    std::cout << a << '\n'; // hello
    std::cout << b << '\n'; // Hello
}

a는 과거의 스냅샷을 "얼려둔 것"입니다: 복사본이라서 원본이 바뀌어도 변하지 않습니다. b는 "원본을 보는 창"입니다: 참조이므로 항상 최신 내용을 보여주지만 그 참조를 통해 변경할 수는 없습니다.

두 방식 모두 유용합니다. 핵심은 무심코 선택하지 말고 의도적으로 선택하는 것입니다.

8. auto&와 const auto& 사용 시 흔한 실수

실수 №1: range-for에서 auto를 사용하고 컨테이너를 변경한다고 생각하는 것.
고전적인 실수입니다: for (auto x : v)는 x의 복사본을 만듭니다. x를 변경하면 복사본만 바뀌고 v의 요소는 바뀌지 않습니다. 컨테이너를 변경할 생각이라면 for (auto& x : v)라고 써야 합니다. 읽기만 하는데 복사를 피하고 싶다면 표준 선택은 for (const auto& x : v)입니다.

실수 №2: 성능 때문에 auto&를 사용했다가 컴파일 오류에 부딪히는 경우.
오른쪽이 임시 객체이거나 const 객체인 경우 비상수 참조는 바인딩되지 않습니다. 이것은 컴파일러의 '성실한' 보호입니다: auto&는 "변경할 것"을 의미하고, 임시나 const는 변경될 수 없습니다. 이럴 때는 보통 const auto& 또는 단순히 auto (복사가 필요할 때)를 사용하면 됩니다.

실수 №3: const auto x와 const auto& x를 혼동해 예상치 못한 복사가 발생하는 경우.
데이터가 작을 때는 차이가 눈에 띄지 않아도, 큰 데이터를 처리할 때는 차이가 큽니다. const auto x는 여전히 복사입니다. 복사를 피하고 싶다면 먼저 &의 존재를 확인하세요.

실수 №4: 참조를 필요 이상으로 오래 유지하여 코드를 복잡하게 만드는 경우.
참조는 편리하지만 "짧게" 사용하는 것이 좋습니다: 참조를 얻어 작업을 하고 즉시 범위를 벗어나게 하세요. auto&를 파일 반 이상에서 중요한 변수로 유지하면 코드가 취약해집니다: 어디서 원본을 변경하고 어디서 독립된 복사를 기대하는지 헷갈리기 쉽습니다. 좋은 스타일은 참조의 범위를 실제로 필요한 블록으로 제한하는 것입니다.

실수 №5: 출력, 집계, 검색, 비교와 같은 경우에 auto&를 사용하고 실제로는 const auto&여야 해서 데이터가 우발적으로 변경되는 경우.
이제는 컴파일 문제가 아니라 의미의 문제입니다. 출력, 카운트, 검색, 비교라면 거의 항상 const auto&가 더 적절합니다. 그렇게 하면 컴파일러가 읽는 동안 데이터를 실수로 "고쳐버리는" 것을 막아줍니다. 이는 문서에 "읽기 전용" 모드를 설정하는 것과 같아서 실수로 텍스트의 절반을 지우는 일을 줄여줍니다.

1
과제
C++ SELF, 레벨 56, 레슨 3
잠금
문자열 스냅샷
문자열 스냅샷
1
과제
C++ SELF, 레벨 56, 레슨 3
잠금
단어 업그레이드
단어 업그레이드
1
과제
C++ SELF, 레벨 56, 레슨 3
잠금
복사 없이 길이 계산
복사 없이 길이 계산
1
과제
C++ SELF, 레벨 56, 레슨 3
잠금
완료 표시
완료 표시
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION