모던 C++ (Modern C++) - GDSC Devtalk 발표 셀프 리뷰

GDSC Devtalk 발표 - Modern C++

배경

우선 이 글은 GDSC Hongik 6th DevTalk 세션에서 제가 직접 발표한 모던 C++에 관한 영상의 셀프 리뷰 겸 다시 정리한 글입니다.

저걸 발표한 게 23년 1월이었는데, 5개월이 지난 지금 기억이 워낙 오래 되기도 했고, 저때 배운 지식과 지금의 지식과 관점이 또 다르기에, 그리고 결정적으로 발표 당시 엄청나게 긴장한 탓에, 워낙 횡설수설하는 바람에 내용이 제대로 전달이 안 된 것 같습니다. 한 번 글로 업데이트하고자 작성합니다.

지금 생각해보면 참 용감했네요. 초보 개발자가 무슨 깡으로 고수님들이 즐비한 GDSC Hongik에서 모던 C++에 대해서 발표를 하려고 했는지. 그래도 덕분에 모던 C++에 관해서 지금까지도 글을 작성해오고 있습니다.

C++을 하게 된 계기이자, 개발을 하게 된 계기

일단 모던 C++에 대해서 관심을 갖게 된 계기는 단순합니다. 제가 C++에 가지는 애정이 있기 때문입니다. 그래서 제가 어떻게 프로그래밍을 접하게 되었는지부터 소개하는 게 좋을 것 같습니다.

제가 C++을 처음 접한 건 2017년이었습니다. 그 전까지는 C 언어를 배우면서 CLI에서 진행되는 간단한 게임이나 만들면서 놀고 있었습니다. 그때 당시가 중학생이었는데, 부모님께서는 제가 게임을 좋아한다는 이유로 컴퓨터 사용을 막기도 했었죠. 사실 그때는 프로그래밍하는 걸 더 좋아했었는데, 프로그래밍을 마치 게임처럼 재밌게 했던 기억이 있어요. 그때 당시 반항심때문에 몰래 컴퓨터를 내 방으로 가져와서 C 언어로 이것저것 만들어보고 배워보고 그랬던 기억이 있습니다.

사실 얼마 지나지 않아서 제가 코딩에 흥미를 느끼고 컴퓨터공학과에 가고 싶다고 말하자 부모님께서도 흔쾌히 지원하시기로 하셨고, 사실 그 당시까지만 해도 컴퓨터공학과가 그리 인기가 없는 과였기 때문에 진로에 대한 걱정은 했었지만, 부모님께서는 제가 좋아하는 걸 하겠다는데 지원을 아끼지는 않으셨습니다.

그래서 여러 C언어 책과 함께, C++ 책도 사주시고, 낡은 컴퓨터 대신 제 기준으론 꽤 좋았던 컴퓨터도 사주셨습니다. Ryzen 5 1600, GTX 1050이 달린 컴퓨터였는데 제가 순수 부품 하나하나 사서 조립했던 기억이 있네요. 지금도 집에 가면 있습니다. 심지어 고2때 산 200만 원 짜리 노트북보다도 성능이 더 좋음

그렇게 해서 본격적으로 컴퓨터 언어에 대해서 배우기 시작했는데, 처음 C++을 배워보고서 사실 꽤 충격이었습니다.

객체지향의 개념을 처음 배우면서, C++이 정말 대단한 언어구나! 하고 감탄했던 기억이 있네요. 하지만 그때까지만 해도 템플릿이라는 벽에 막혀서.. 결국 C++을 제대로 사용하는 건 포기했습니다. 지금보면 별 것도 아닌데 그때 당시엔 템플릿이 이상하게 그렇게 어렵더라구요. C언어의 typedef를 이용한 타입 추상화도 어려워했었는데, 그거와 직접적으로 연결된 내용이다 보니 아마 어려워했던 것 같아요.

이후에 알파고와 이세돌의 대결이 펼쳐지면서 인공지능에 대한 관심이 생겼고, 인공지능을 배워보아야 겠구나 라는 생각을 했습니다. 그래서 인공지능을 배우려면 무엇을 해야 하지 알아보던 중, 파이썬을 알게 되었죠. 그래서 중3 때는 파이썬을 배우기 시작했습니다.

파이썬이 저한테는 꽤 어려웠는데, C++보다 덜 체계적으로 배운 까닭도 있고, 무엇보다 처음 배운 C와는 너무나 이질적인 구조(지금의 언어로 표현하면 동적 타입 언어이자 스크립트 언어, 그리고 딱히 main 함수를 정의하지 않는 형태 등) 때문에 상당히 고생했었습니다. 파이썬 파일 하나 분리하는 것도 몰라서 모든 코드를 파이썬 내부에 작성하고 그랬었죠.

그래도 파이썬과 C언어로 고등학교 때까지 꽤 잘 써먹었습니다. 고등학교 1-2학년 동안 과학 동아리에서 한 활동으로, 주변 지역의 미세먼지 통계치를 2년 동안 수집했었는데, 이에 대한 통계치를 구하는데 파이썬을 사용했었고, 특히 2학년 때 한 것 중에 가장 기억에 남는 것이 행성 운동에 관한 케플러의 정리(케플러 법칙)을 이용해서 행성의 공전 운동을 컴퓨터로 모델링하는 프로젝트(?)였습니다. 그떄 같이 컴퓨터공학과를 준비하는 친구와 함께 2명이서 진행했었는데, 파이썬과 기억도 안 나는 정체 모를 언어를 이용해서 행성의 공전 운동을 모델링했던 기억이 있습니다. 이렇게 말하면 거창하고 대단해보이는데 사실 이미 남들이 해놓은 거, 이미 있는 공식 사용해서 정말 별거 아닌 거에 포장을 기깔나게 했죠. 그래도 그때는 재밌었습니다.

암튼 그렇게 해서 고등학교를 보내고, 한동안 프로그래밍은 손을 놓고 있다가 대학교에 와서 다시 시작했습니다. 1학년 2학기 때 자료구조와 프로그래밍이라는 과목에서 C++을 사용하게 되는데, 이때 수 년만에 다시 다시 C++을 배워보겠다는 생각을 했습니다.

물론 C++ 기초부터 배우는 건 아니고, 사실 C++14, C++17 등 표준이 계속해서 바뀌고 있다는 건 이전부터 알았거든요? 새로운 표준이 적용된 C++, 그리고 입문자를 위한 C++이 아닌 실제 현장에서 사용되는 C++은 어떻게 짜여지고 있을까 궁금해서 배우기 시작했습니다.

배우면서 느낀 점은 제가 처음 C++을 배웠을 때, 객체지향에 대해 배웠을때 느꼈던 충격과 동일한 수준의 신선한 충격을 받았습니다. 특히 객체의 생명 주기에 관한 내용, 그리고 이에 관련된 RAII 원칙이 너무나 마음에 들었고, 무엇보다 항상 저를 고통스럽게 했던 newdelete에서 벗어날 수 있었다는 게 정말 매력적이었죠.

그리고 이때 간단하게나마 C++의 병렬 처리 등에 대해서도 체계적으로 배웠는데, mutex, async, future/promise 등에 대해서 배우면서 재밌다는 생각을 했습니다. 그래서 제가 배우고 느낀 점을 정리하고자 발표를 준비했습니다.

발표는 특히 이펙티브 모던 C++, 코드없는 프로그래밍 채널, Naver Deview의 Modern C++ 무조건 써야해? 등의 자료를 많이 참고했습니다.

모던 C++에 관한 이야기

모던 C++이란?

C++11 표준 이후에 나온 C++을 의미한다. 즉 C++11을 포함하여 14, 17, 20, 23 등의 버전을 의미한다.

용어 정리를 하자면, 본 글에서 클래식/올드 C++이라 함은 C++03 버전 이하를 의미한다. 모던은 위에서 말한대로 11 버전 이후를 의미한다. 그러나 단순히 버전의 차이도 있지만, 그보다 중요한 것은 바로 어떻게 코드를 작성하는 지에 관한 것이다. C++23 버전을 사용하고 있더라도, 코딩 스타일을 과거의 유산을 따른다면 그건 그냥 클래식 C++을 사용하는 것이다.

아래 표로 간단하게 내가 생각하는 C++의 주요 변천사와 도입된 기능들을 정리했다. 참고로 C++03의 경우 C++98에서 몇 가지 내부적 수정만 가한 형태이기에 기능상의 변화는 거의 없이, 98 버전과 거의 동일하다.

C++ Version Major Features
C++98 STL, templates, std::string, I/O streams
C++11 move sementic, auto & decltype, lambda,
constexpr, std::thread, smart pointer,
regular expression, std::array, std::tuple, hashmap, ranged based for loop
C++14 generic lambda, return type deduction,
reinforced constexpr, reader-writer lock
C++17 fold expression, constexpr if, std::string_view,
Parallel Algorithms for STL,
Advanced File System Library
, std::any, std::optional, std::variant
C++20 std::jthread, coroutines, template concept, semaphore, range, module, std::span
C++23 contract, auto r-value referencing,
multi-dimensional operator[],
reflection (stack trace), advanced range, std::expected, std::mdspan

 

 

참고로 위 기능들 중 일부는 이미 별도의 포스팅을 적어두고 있다.

더 나아가서 사실 발표에서 소개했던 내용들 대부분은 별도의 글로 따로 작성해두었다.

주의사항

본격적으로 모던 C++에 대해 이야기하기 앞서, 항상 이런 글을 읽거나 쓸 때에 강조하고 싶은 주의사항이 있다. 여기서 소개하는 기능들이 전부 유용하거나 모든 프로젝트에 적용될 수 있는 것은 아니다. 사실, C++라는 언어 자체가 모두를 위한 범용성 있는 언어는 아니다.

C++의 창시자 비야네 스트로스트롭(Bjarne Stroustrup)이 직접 C++가 모두를 위한 언어는 아니라고 말한다.

  • Not perfection
  • Not everything for everybody

더 나아가서 C++가 근본적으로, 그리고 실무적으로 지원해주지 않는 것들에 대해서도 말한다.

  • No crucial dependence on a garbage collector
    • GC is a last and imperfect resort
  • No guaranteed type safety
    • Not for all constructs
    • C compatibility, history, pointers/arrays, unions, casts, …
  • No virtual machine
    • For many reasons, we often want to run on the real machine
    • You can run on a virtual machine (or in a sandbox) if you want to
  • No huge “standard” library
    • No owner
      • To produce “free” libraries to ensure market share
    • No central authority
      • To approve, reject, and help integration of libraries
  • No standard
    • Graphics/GUI
      • Competing frameworks
    • XML support
    • Web support

근본적으로 C++의 한계는 C++의 창시자도 인정을 한다. 표준화된 GUI나 그래픽 프레임워크도 없고, 웹을 지원해주지도 않는다. 가비지 컬렉터의 부재로 인한 자원의 관리 책임 역시 존재한다. 심지어 의외의 사실이지만 C++는 타입이 있는 언어이나, 사실은 캐스팅이 꽤나 자유로운 편으로 타입 안정성을 완벽하게 보장해주지 않는다.

이러한 구조적 단점은 모던 C++에서도 크게 해결되지는 않는다. 대신 모던 C++는 RAII idiom의 도입, 현대화되고 표준화된 새로운 코딩 스타일과 문법 & 라이브러리들, 그리고 강력한 병렬 처리의 지원과 이를 통한 성능적 이점을 취하는 등, C++이기에 가능한 장점들을 극대화한다.

그러니 무조건적으로 새 기능을 수용하거나 비판하기 보다는, 관심이 가는 기능이라면 여기서 소개된 내용 외에 더 찾아보면서 그 기능이 정말로 유용하고 도입될 수 있는지, 비용과 트레이드오프를 고려해가면서 결정하는 것이 바람직하다.

모던 C++의 특징

모던 C++을 사용하는, 주된 장점이자 목적, 그리고 특징을 크게 네 가지로 정리했다.

  1. RAII: 모던 C++은 RAII 원칙을 준수한다.
  2. 성능: 모던 C++은 성능적인 개선점 역시 크게 증가하였다. 발표에서 주로 다루지는 않았지만, constexpr, std::string_view, std::span 등은 성능적 이점을 얻기 위해 도입되었으며, iostream의 구조적 개선, module을 통한 컴파일 시간의 단축 등은 C++이 발전하면서 취한 성능적인 이득을 불러왔다
  3. 표준화: 이는 Portable(포터블) 함을 의미한다. 즉 내가 작성한 C++ 코드를 어떠한 디바이스 환경에서도 실행시킬 수 있음을 의미한다.
    • 이전에는 그게 안 됐냐? 생각보다 쉽지는 않았다. 물론 C++은 더 이전 과거에 비하면 범용으로 사용되기 편리한 언어이고, CMake 등의 빌드 툴의 발전으로 각 디바이스에 맞는 실행 파일을 생성하는 것 역시 편리해졌다 (물론 CMake 역시 간접적인 모던화의 혜택이라고 볼 수 있다)
    • 그러나 여전히 C++98 이전까지, Linux, Window, MacOS, Embedded device에서 사용되는 코드가 다른 경우가 많았다. 특히 병렬 처리의 경우가 C++11 전까지는 표준화되지 않아서, 각 플랫폼마다 서로 다른 코드를 작성해주어야 했다.
  4. 가독성: 가독성은 조금 이견이 나올 수가 있는데, C++의 표준을 관리하는 위원회의 주장으로는 functional의 도입, ranged view 등의 도입으로 가독성이 개선되었다고 한다.
    • 다만 모던 C++에 와서 코드를 더 '선언적'으로 작성하게 되고, 일관된 STL api를 사용하게 되는 등의 혜택은 갖게 되었지만, 그것이 가독성을 높였다고 받아들이기에는 사실 어느 정도 한계가 있는 것이 사실이다. 여전히 C++의 악명 높은 타이핑 양은 코드의 길이를 길게 만들고, 많은 모던 기능들은 아직도 널리 퍼지지 않은 탓에 오히려 가독성을 헤치는 요소이기도 하다.

가독성

가독성에 대한 이야기가 나왔으니 조금 더 덧붙이자. 사실, C++이 가독성이 좋은 언어라고 하기엔 어려운 것이 사실이다. 특히 지금은 많이 개선되었지만, 일부 레거시 코드들을 보면 과도한 템플릿의 사용과 지나친 추상화로 인해 읽기 힘든 코드들이 존재한다. 또한 아직도 몇몇 모던 기능들은 잘 알려지지 않은 탓에, 이 코드가 무엇을 하는 지 직관적으로 파악하기 어려운 것도 있다.

아래는 취향이긴 한데, 무엇이 더 코드의 가독성이 좋다고 생각하는가? 어느 쪽의 코드가 더 직관적이고, 곧바로 무엇을 하는 지 알 수 있겠는가?

전통적인 방법의 경우

int filterNumber(int number) {
    if (number % 2 == 0) {
        return number;
    }
    return 0;
}

int calculate(int bottom, int top) {
    if (top < bottom) {
        return 0;
    }

    int sum = 0;
    for (int number = bottom; number <= top; number++) {
        sum += filterNumber(number);
    }
    return sum;
}

모던화된 경우

auto calculation(int bottom, int top) {
    if (bottom >= top) return 0;
    auto even = [](auto e) { return e % 2 == 0; };
    auto evens = std::views::iota(bottom, top + 1) | std::views::filter(even);
    return std::accumulate(evens.begin(), evens.end(), 0);
}

혹은 ternary operator를 사용한 경우

auto calculation(int bottom, int top) {
    return top <= bottom ? 0
            : ranges::accumulate(
                std::views::iota(bottom, top + 1) |
                std::views::filter([](auto e) { return e % 2 == 0; }),
                0);
}

무엇이 더 직관적인가? 아래로 갈수록 코드는 더 선언적이고, 간결해지는 건 분명하다. 그러나 무엇이 더 직관적이고, 코드의 행동을 명확히 추론해낼 수 있는지는 사람마다 의견이 갈릴 것 같다. 이는 내 생각에 익숙함과 취향의 문제이자 선택의 문제다. 하지만 분명한 것은 사람에 따라 모던 C++의 가독성이 더 좋다고 확언할 수는 없다는 뜻이 된다.

참고로 위의 코드들은 Why You Shouldn't Nest Your Code, From C ➡️ C++ ➡️ Rust에서 나온 코드들을 일부 수정하였다. 위 세 개의 코드는 모두 bottom부터 top까지 짝수들의 합을 구하는 함수이다. 그리고 ranges::accumulate를 사용하기 위해선 range-v3 라이브러리의 설치가 필요하다. range-v3는 STL ranges 라이브러리를 확장 지원하는 라이브러리이다.

표준화

모던 C++에서 가장 큰 표준화의 혜택을 본 것은 병렬 처리에 관한 내용이다. 이전까지만 해도 윈도우, 맥OS, 리눅스, 기타 임베디드 시스템에서의 병렬 처리 방법은 모두 달랐다. 그러나 C++11부터 thread 헤더 등 병렬 처리를 위한 표준 라이브러리가 도입되었고, 지금까지 이들이 계속 발전하면서, 이제는 각 디바이스에 맞추어서 병렬 처리를 해야 할 필요성 없이, 그냥 표준화된 코드 하나만 가지고 각 플랫폼에 맞는 실행 파일을 만들 수 있게 되었다.

그 외에도 chrono 라이브러리의 도입도, 시간 관련 기능을 표준화하였다. 이전까지만 해도 스레드와 마찬가지로 시간 역시 리눅스, 윈도우 등이 사용하는 라이브러리가 모두 다 달랐다. 작년 자료구조 과제를 할 때에도 각 정렬 알고리즘 별 시간 복잡도 성능을 측정해야 했는데, 리눅스와 윈도우에서의 시간 관련 헤더가 달라서 코드가 달라져야 했고, 그래서 결국 이를 래핑하는 코드를 윈도우 환경에서 별도로 만들어서 해결해야 했다. C++11부터 도입된 chrono 라이브러리는 시간 관련 작업을 표준화하는 라이브러리로 이러한 문제를 해결하였다.

filesystem 라이브러리 역시 플랫폼마다 달랐던 파일시스템 관리를 표준화했다. 자세한 내용은 공식 문서 링크를 달아두었으니 참고하길 바란다.

성능

특히 constexpr이 그 예시인데, 이에 대한 내용은 [모던 C++] 컴파일 상수, constexpr 이 글에서 다루었으니 참고하길 바란다.

한편 C++20부터는 모듈 시스템이 도입되었고, 이는 컴파일 시간을 아껴주는데 도움이 되기도 했다. 그러나 아직까지 모듈 시스템은 익숙하지도 않고 널리 사용되지도 않아서, 그리고 내가 한 번도 써본 적이 없으니깐 뭐 말할 수 있는게 없다. 이에 대한 내용은 생략하겠다.

RAII

RAII에 관한 내용은 방대하기도 하고, 또한 정말 중요한 내용이기 때문에 별도의 글로 분리하였다.

다만 영상에서 했던 말에 주의 사항 겸 덧붙이고 싶은 말이 있다. 발표에서는 RAII의 자동화라는 관점을 강조하기 위해 '알아서'라는 표현을 사용하였다. 그러나 발표가 끝난 후 개인적으로 들어온 질문에서, 이 말이 심각한 오해의 소지를 불러일으킬 수 있는 실수였음을 알게 되었다.

질문은 다음과 같았는데, 스마트 포인터가 가비지 컬렉터같은 것이라는 물음이었다. 당연히 아니다. 스마트 포인터는 객체의 라이프 사이클과 자원이 라이프 사이클을 일치시키는 역할을 할 뿐, 런타임 중 전역적으로 자원을 감시하다가 도중에 개입하여 필요없어진 자원을 해제하는 가비지 컬렉터와는 그 동작 방식이 아예 다르다. 애초에 성능을 중요시하는 C++에서 가비지 컬렉터를 도입하는 순간 C++을 사용할 이유가 전혀 없어진다. 그럴 바에는 그냥 C#이나 Java를 쓰지.

그러나 자원을 적당한 시점에 알아서 사라지게 만든다는 말이, 마치 가비지 컬렉터의 형태를 의미하는 것처럼 들릴 수 있음을 깨달았다. 자원을 적당한 시점에 알아서 해제한다는 말은 자원의 해제가 객체의 라이프 사이클과 일치하여, 객체가 파괴될 시점에 알아서 해제가 된다는 의미였다. 객체의 라이프 사이클은 비교적 파악하기가 수월하고, 스택 위에 올라간 객체는 따로 추적 관찰하지 않아도 알아서 파괴가 이루어지기 때문이다.

몇 가지 주요 기능 소개

발표에서 소개한 기능들중 일부는 이미 별도의 포스팅으로 다룬 바가 있다. 다루지 않은 내용만 잠깐 간단히 소개하겠다.

형식 추론: auto, decltype

다른 언어와 마찬가지로 C++ 역시 형식 추론을 지원한다. C++에서는 auto 키워드를 사용한다.

std::string str = "Hello, World!";

for (std::vector<int>::iterator iter = v.begin(); iter != v.end(); ++iter) {
    // ...
}

위 코드는 특히 이터레이터의 긴 변수명으로 인해 가독성을 떨어뜨린다. 아래로 바꿔보자.

auto str = "Hello, World!";

for (auto iter = v.begin(); iter != v.end(); ++iter) {
    // ...
}

auto 키워드는 특히 iterator처럼 자명하지만 다소 긴 형식에서 사용하기 편리하다. Effective Modern C++에서는 가능한 한 auto를 사용할 것을 권장한다.

주의: auto와 decltype의 추론형이 다름을 기억하라

그러나 주의점이 있다. 바로 decltypeauto의 각 영역 추론 형식이 다르다는 점을 기억해야 한다. 이에 대한 포스팅은 추후에 별도로 작성하겠다.

주의: auto가 오히려 가독성을 헤칠 수 있다는 것, 그리고 auto parameter가 template을 생성한다는 것을 기억하라

또한 한 가지 더 고민해야 할 점은, 바로 무분별한 auto가 가독성을 떨어뜨릴 수도 있다는 것, 그리고 템플릿으로써의 auto 사용을 허용할 것인가에 대한 문제이다. 예를 들어서 다음 코드를 보자.

constexpr auto add(const auto &a, const auto &b) {
    return a + b;
}

auto func() {
    constexpr auto result0 = add(1, 2);
    constexpr auto result1 = add(1.0, 2.0);

    std::string str1 = "Hello, ";
    std::string str2 = "World!";
    const auto result2 = add(str1, str2);

    constexpr auto result3 = add(1, 2.0); // 이것의 결과 타입은?
    constexpr auto result4 = add(1.0f, 2); // 이것의 결과 타입은?
    constexpr auto result5 = add(1.0, true); // 이것의 결과 타입은?
}

result0, 1, 2까지는 별 문제없다. string이 조금 걸리기는 하지만, 뭐 어쨌든 결론적으로 예상했던 행동이었을 수 있다. 그러나, 과연 result3의 타입은 어떻게 될까? 이를 쉽게 예측할 수 있겠는가? result4, 5는?

정답은 result3의 타입은 double이다. 이는 C++에서는 자동적으로 업 캐스팅의 방향으로 형식이 결정되기 때문이다. return int + double 형에서 업캐스팅이 일어난다.

result4의 경우는 float이다. return float + int에서 float으로 업캐스팅이 발생한다.

마지막으로 result5의 경우 double이고, 값은 2.0이다. return double + bool에서 bool이 double로 업캐스팅되고, 2.0으로 변환된다.

int main() {
    constexpr auto result0 = add(1, 2);
    constexpr auto result1 = add(1.0, 2.0);
    std::string str1 = "Hello, ";
    std::string str2 = "World!";
    const auto result2 = add(str1, str2);
    constexpr auto result3 = add(1, 2.0);
    constexpr auto result4 = add(1.0f, 2);
    constexpr auto result5 = add(1.0, true);

    std::cout << typeid(result0).name() << ' ' << result0 << '\n';
    std::cout << typeid(result1).name() << ' ' << result1 << '\n';
    std::cout << typeid(result2).name() << ' ' << result2 << '\n';
    std::cout << typeid(result3).name() << ' ' << result3 << '\n';
    std::cout << typeid(result4).name() << ' ' << result4 << '\n';
    std::cout << typeid(result5).name() << ' ' << result5 << '\n';
}
i 3
d 3
NSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE Hello, World!
d 3
f 3
d 2

위에 결과는 따지고 보면 논리적으로는 보이나, 일반적으로 쉽게 예측하기 어렵고, 가독성도 떨어진다. 게다가 정의되지 않은 템플릿으로써의 auto 사용이 예상하지 못한 행동으로 이어질 수 있음을 시사한다.

auto를 사용한 템플릿은 아래와는 전혀 다른 의미를 갖는다.

template <typename T>
constexpr T add(const T &a, const T &b) {
    return a + b;
}

위에는 a, b의 타입이 같음을 시사하는데, auto는 그렇지 않다.

마찬가지의 문제는 lambda expression을 사용할 때도 발생한다.

std::vector<std::pair<double, double>> pairs;

return std::accumulate(
  pairs.cbegin(), pairs.cend(), 0,
  [](auto acc, const auto& pair) {
      return acc + pair.first * pair.second;
});
``

람다 함수를 사용해서 값을 더하고 있는데, 가독성이 좋아보이는가? acc와 pair의 이름을 무시하고 보면, 람다 함수의 인자값에 무엇이 들어오는 지 직관적으로 와닿지 않는다. 이를 그냥 정확한 타입으로 나타내면 어떨까?

```cpp
struct Outcome {
  double probability = 0;
  double value = 0;
};

std::vector<Outcome> distribution;

return std::accumulate(
  distribution.cbegin(), distribution.cend(), 0,
  [](double acc, const Outcome& outcome) {
      return acc + outcome.probability * outcome.value;
});

코드가 훨씬 더 명확해보인다. 위 코드는 Don't automatically use auto parameters in C++에서 발췌하였다.

위 글에서도 하는 주장은 같다. auto parameter는 template을 생성하기에 사용에 주의해야 한다는 것이다. 그래서 일반적으로 특수한 경우(자명한 람다 식의 사용 등)을 제외하고는 파라미터에 auto를 사용하지 않는 것이 좋다.

함수 반환형 추론

auto 타입 연역 부분에서 시사했듯이 auto는 함수 반환형을 추론하는데 사용될 수도 있다. 이는 C++14에서부터 추가된 기능이다.

예를 들어 다음 코드를 보자.

class A {
public:
    inline const std::unordered_map<std::string, std::vector<std::string>> &getHashMap() const noexcept {
        return hash_map;
    }

private:
    std::unordered_map<std::string, std::vector<std::string>> hash_map;
};

함수형이 길고 복잡하다. 반환형이 너무 길다. 어차피 해시맵을 반환하는 건 자명한데, 이를 auto로 축약해보는 건 어떨까?

class A {
public:
    inline const auto &getHashMap() const noexcept {
        return hash_map;
    }

private:
    std::unordered_map<std::string, std::vector<std::string>> hash_map;
};

이렇듯이 auto는 함수 반환형 추론, 그리고 타입 추론 시에 유용하게 사용된다. 함수 반환형 추론 역시 Effective Modern C++에서 사용하길 권장하고 있다. 다만 여기서도 마찬가지로 decltype과 auto가 다르다는 점, 그리고 decltype(auto)라는 언뜻 보기에는 이상한 문법도 자주 사용될 수 있음을 기억하자.

auto에 관한 글은 별도로 작성할 예정이다.

tuple과 pair, 그리고 unpacking

C++에도 파이썬과 같이 튜플이 존재한다. 튜플은 페어보다 더 많은 값을 갖고 있는 일종의 구조체이다.

그리고 튜플, 페어 모두 언팩킹을 지원한다. 다음 코드를 보자.

std::tuple<std::string, std::string, int> Student::getStudentInfo() const {
    return {this->name, this->major, this->age};
}

void func() {
    auto [name, major, age] = student.getStudentInfo();
    std::cout << name << ' ' << major << ' ' << age << '\n';
}

언팩킹이란, func의 첫 번째 줄과 같이 auto [arg1, arg2, arg3] 의 형태로 튜플의 값을 별도의 변수로 저장하는 것을 의미한다. 여기서 name, major, age의 타입은 자동으로 추론된다.

페어에서도 이는 동일한데, 이때문에 pair.first, pair.second로 사용하는 것보다 언팩킹을 통해 의미 있는 이름을 갖는 변수로 저장하는 게 더 유리하다.

std::vector<std::string, Student> students;
// ...

// pair.first, pair.second로 사용하는 대신 의미있는 변수를 부여하라
for (const auto &[id, student] : students) {
    std::cout << id << ' ' << student.getName() << '\n';
}

move semantic

무브 시멘틱, 혹은 이동 생성자에 관한 이야기이다. pushemplace에 관한 이야기도 섞여 있다.

std::vector<std::string> v;

v.push_back("hello"); // P0 : 임시 객체 생성에 의한 복사 생성 -> 임시 객체 소멸
v.emplace_back("hello"); // P1 : r-value 전달에 의해서 복사 생성 없이 그대로 std::string 객체 생성 삽입

std::string s = "hello";
v.push_back(s); // P2 : 복사 생성
v.emplace_back(s); // P3 : 복사 생성

v.emplace_back(std::move(s)); // P4 : 성능 차이 발생

위의 예시를 통해 각각의 케이스를 구분해보자.

  • P0는 "hello"라는 const char *가 전달되었다. push_back은 내부적으로 임시 std::string("hello") 객체를 생성한 후, 이 임시 객체에 대한 복사 생성자를 호출해서 v에 삽입한다.
  • P1는 emplace_back 함수를 사용했다. emplace_back은 내부적으로 임시 객체를 생성하지 않고, r-value move semantic을 통해 복사 생성 없이, 곧바로 std::string 객체를 생성해서 v에 삽입한다.
  • P2와 P3는 차이점이 없다. 둘다 l-value를 전달하고 있으므로 복사 생성을 수행한다.
  • P4는 차이가 있다. std::move를 통해 l-value를 r-value로 캐스팅한다. 그래서 이동 생성(move semantic)으로 임시 객체의 생성 없이 v에 삽입된다.

다만, RVO(Return Value Optimization)이 적용되면 이야기는 또 달라진다. 컴파일러에서 최적화 옵션을 -O2-O3 등으로 주게 되면 RVO를 적극적으로 적용하는데, 이 경우에 컴파일러 최적화를 통해서 push_back을 사용해도 임시 객체 생성 없이 바로 std::string을 삽입할 수 있기 때문이다. 더 나아가서 어떠한 경우에는 RVO를 통한 최적화의 결과가, move보다 더 좋은 경우도 있어서, 의도적으로 RVO를 노리기 위해 push_back을 사용하거나, std::move를 사용하지 않기도 한다. 사실 move semantic 역시 어쨌든 캐스팅, 그리고 이동 연산으로 인한 O(1)의 시간이 발생하는데, RVO는 컴파일 과정에서 최적화가 적용되면 O(1)의 시간도 없애는 경우도 있기 때문이다.

다만 RVO의 최적화가 언제 정확히 어떻게 적용되는 지는 직접 공부해야 하는 영역이므로, push_back을 쓰는지 emplace_back을 쓸 건지에 대한 논의는 좀 더 지켜보아야 할 것 같다.

여기 두 상반된 의견의 자료를 첨부한다. 두 자료를 비교해가며 어떤 선택을 할 것인지는 본인의 몫이다.

랜덤 엔진

이에 대한 내용은 [모던 C++] C++에서 랜덤값 얻기/ rand() 함수 쓰면 안 되는 이유 에서 다루었다.

C++의 미래

 

한편 C++23에 대한 이야기를 잠깐 해보겠다. C++23에서는 병렬 처리에 대한 표준 기능이 더 강화된다고 한다. std::future에 대한 기능이 더 강화되고, 그 외에도 병렬 처리에 대한 여러 기능들이 도입된다고 한다.

사실 std::expected 역시 C++23에 와서야 도입되었다. 그리고 23 버전에서 여러 자잘한 변화들이 이루어진 것도 사실이다.

다만 한 기사에 따르면, 펜데믹 이슈로 인해서 C++ 표준 위원회의 일 역시 딜레이되었고, 그때문에 원래 C++23에서 도입되기로 한 많은 기능들은 C++26으로 이전되었다고 한다.

현재 C++23의 표준이 제정되고 배포되는 중이다. 다만 사실 아직까지도 일부 컴파일러들은 C++20의 기능들을 모두 지원하지조차 못한다. C++23의 기능들이 안정적으로 정착되기까지 얼마나 걸릴지도 모르겠다.

새 표준이 도입될 때 주의해야 할 점은, 사실 새 기능이 도입되었다고 무작정 사용하는 것은 위험하다. 특히 규모가 클수록 매우 보수적으로 접근해야 하는데, 새 기능의 경우 아직 검증되지도 않았고, 모범 사례를 확보하기에 충분한 사용자 풀과 사용 경험들이 갖추어지지 않았기 때문이다. 그래서 왠만하면 긴 시간을 두고 C++ 커뮤니티에서 새 기능에 대한 충분한 지식과 경험들이 쌓였을 때, 그리고 그 효용이 입증되었을 때 도입해도 충분히 늦지 않는다.

여러분은 어떤 기능이 앞으로 더 나왔으면 하는가? 개인적으로는 유니코드에 대한 완전하고 편리한 지원이 기대된다. 놀랍게도 아직까지도 C++은 유니코드를 기본으로 지원해주지 않는다. C++에서 유니코드를 지원하려면 번거로운 과정을 거쳐야 한다. 더 미래의 C++에서 유니코드에 대한 지원이 편리해지면 좋겠다.

여기 C++의 미래에 도입될 기능들에 관한 영상을 링크한다.

한편 C++의 더 장기적인 미래는 어떻게 될까? 개인적으로도 너무 궁금하다. C++은 정말 역사가 오래된 언어이고, 오래된 역사만큼이나 정말 널리 쓰이는 언어이다. 이렇게 널리 쓰이는 언어가 단기간에 사라질 거라 예측하는 건 쉽지 않다.

그러나 한편으론 C++가 과연 20년, 30년 후 미래에도 지금과 같은 지위를 유지하고 있을거라 생각하기에 쉽지 않은 것도 사실이다. C언어의 역사는 정말 오래 되었고, 지금까지도 계속해서 남아있다. 그러나 이제 대부분의 기업에서 새 프로젝트를 시작할 때 C언어를 선택하기 꺼려하는 것도 사실이다. 레거시 코드를 유지보수하기 위해서 C언어는 계속 남아있을 예정이지만, 그것이 C언어가 현역으로 활발히 활동한다고 보긴 어렵다.

C++도 마찬가지의 신세가 될 수도 있다. 미래에도 C++은 사라지지 않을 것 같다. 수십 수백 억 줄에 달하는 C++ 코드베이스를 바꾸는 건 정말 어려운 일이다. 그래서 레거시 코드 베이스를 유지 보수하는데 있어 C++을 사용해야 하는 것은 마찬가지일 것 같다. 그러나 과연 미래에도 새로운 프로젝트를 시작하는데 쉽게 C++을 사용할 수 있을까? 이미 C++을 대체하기 위한 여러 언어들이 등장하였고, 앞으로도 계속해서 등장할 것이다. 그때에도 지금과 같은 위상을 갖고 있을지는 모르겠다.

그러나 한편으로는 여전히 C++가 우세한 분야들이 있다. 특히 게임 분야는 C++가 절대적 강자라고 들었다. 다른 곳보다 더 빠른 처리가 중요한 게임 서버는 C++를 많이 사용한다고 들었고, 일단 언리얼 엔진부터가 C++를 요구하다보니깐... 또한 jetbrain 사의 개발자 인포그래픽 현황을 보면 22년에 오히려 C++의 점유율이 상승하였다. 이런 걸 보면 함부로 C++의 미래가 어떻게 될 것이다 재단하긴 어려운 것 같다.

C++에 대한 나의 생각

사실 C++가 개인적으로 설계가 잘 된 언어라고 보기는 힘들다. 많은 곳들에서 사실 설계상의 결함들이 발견되었고, 이를 덮기 위한 흔적들도 많이 보인다. 그도 그럴 것이 C++는 역사가 정말 오래된 언어이고, 그만큼 관리되어야 할 레거시 코드들이 정말 많다. C++ 표준 위원회의 고민은 새로운 컴파일러와 버전이 기존 레거시 코드들과의 호환성을 유지하면서도 새 기능을 무사히 정착시키는 것이다.

문제는 기존 코드에서 설계상의 결함을 발견하였을 때 발생한다. 기존 코드 그대로를 뜯어고치는 것은 과거의 유산을 망가뜨릴 수 있기 때문에, C++ 표준 위원회는 결국 이를 대체하는 새로운 대안 표준을 도입하기도 한다. 가끔 STL을 쓰다보면 이름 뒤에 '_t' 가 붙은 것들을 볼 수 있다. 대게는 그러한 것들은 기존의 표준이 무언가 문제가 있는데, 이를 고치는게 쉽지 않아서 대신 새 대안 표준을 도입한 경우이다.

이러다보니깐 언어의 복잡성은 더욱 올라가고, 비슷한 역할을 하는 것처럼 보이는 기능들이 많아진다. 같은 결과물을 만들기 위해서 가능한 방법들이 점점 더 많아지고, 또 그 중 몇몇은 함정이 섞여있다. 이는 사용자에게 어떤 방법을 사용하면 좋을지 계속 혼란스럽게 만들어서, 결국 신규 유입의 벽을 높이는 결과를 가져오기도 했다. 이는 사실 흔히 역사가 오래된 언어들이 갖고 있는 고질적인 문제점이기도 하다.

그럼에도 불구하고, C++은 3년마다 계속해서 진화하려고 하는 언어이다. 모던 계열에 와서, C++의 자존심(?)을 버리고 공격적으로 다른 언어에서 차용 중인 많은 기능들을 가져와 도입했고, 그래서 최신의 C++은 기능만 놓고 보면 다른 언어들이 지원하는 많은 기능들을 지원하려고 한다. 계속해서 도태되지 않으려고 끊임없이 진화하는 언어이고, 이 모습은 적어도 C++이 lisp나 cobol처럼, 정말 어느 시점에 와서 '죽은' 언어로 전락하지는 않을 것이란 확신을 안겨주는 듯 하다.

알면 알수록 매력적인 언어이다.

후기

사실 발표 자체가 내용이 혼잡합니다. RAII를 다룰 거면 그것만 다루던지, 모던 기능을 다룰 거면 불필요한 내용은 제외하고 담백한 모던 기능을 소개하든지 했어야 했는데, 15분 내에 많은 내용을 녹이려 하다보니 난잡해진 감이 있는 것 같아요.

그래도 이때 발표에서 C++의 매력을 많이 알린 것 같아서 만족스럽습니다. C++은 끊임없이 발전을 계속하는 언어이고, 언어 자체가 사람들의 호불호를 정말 많이 타는 언어이에요. 사실 개인적으로도 C++가 설계가 제대로 된 언어가 아니라는 생각도 드는데, 그럼에도 불구하고 계속해서 진화하는 언어라는 점, 그리고 프로그래머에게 주는 자유가 정말 크다는 점은 매력으로 다가와요.

한편 저 날 다른 분들께서 워낙 좋은 발표들을 많이 해주었습니다.

특히 개인적으로 꼭 보길 강추하고 싶은 영상이 있는데, 디미터 법칙에 관한 영상은 꼭 한 번 찾아보길 바래요. 객체 지향의 설계에 대해서 심도있는 고찰을 할 수 있게 해줍니다.

그리고 이 날 발표는 아니었는데, 개인적으로 모던 C++이 등장하게 된 배경에 관련된 영상으로, [GDSC Hongik] C언어의 문제점과 해결책 이 영상도 링크하고 싶네요. 이 영상에서 소개된 내용 중 다른 것보다 C언어의 안전하지 못한 메모리 관리 문제는, 클래식 C++에서도 동일하게 적용됩니다. 그것이 RAII idiom이 등장하게 된 배경입니다.

Rust의 메모리 관리 체계가 어떻게 되는 지는 정확히 모르겠는데, 러스트의 소유권 개념이 아마 C++의 스마트 포인터의 소유권 개념하고 비슷한 맥락을 갖는 것 같습니다. 규모가 커진 프로젝트에서, 안전하지 못한 포인터의 사용은 개발자들의 실력과 관계없이 문제가 발생합니다. 규모가 커질수록 raw pointer는 소유권에 관한 책임 관계가 점점 불분명해지므로, 이로 인한 생산성의 저하와 문제 발생은 필연적으로 증가합니다. 구글에서도 이를 인정하고 스마트 포인터를 사용할 것을 강제하고 있죠(Ownership and Smart Pointers).

그리고 이는 개개인의 부주의함과는 별개의 문제입니다. 결국 협업을 하는 관점에서는 아무리 누군가가 주의를 기울여서 자원 관리를 한다 하더라도, 어느 누군가가 이 균형과 틀을 깨부수는 순간 모든 라이프 사이클의 순환과 자원의 흐름은 깨지게 됩니다. 그래서 규모가 커질 수록 더욱 더 전체적인 엔지니어들이 다같이 혜택을 누릴 수 있는 안전성이 중요해지고, 메모리 관리를 자동화하는 방법, 누군가의 실수를 줄여줄 수 있는 방법들에 대해 고민하게 될 시점이 옵니다(프로그래머로 살아남기 위해 필요한 언어 둘, 4분 30초 부분). 왜냐하면 개발은 나 혼자, 혹은 실수 없이 완벽하게 잘하는 사람들 한 두 명이서 하는 게 아닌, 모두가 함께 협업하는 일이기 때문입니다. 또한 애초에 실수가 없다는 말은 그만큼 코드에 내용을 담는 시간보다 형식을 지키는 시간이 많아지면서 발생하는 생산성의 저하를 내포하기도 하죠.

그렇기에 어느 시점부터는 모두가 잘 할 수 있는 환경, 모두가 실수하지 않도록 하는 코딩 스타일을 고민해야 하는 것입니다.

이상입니다. 암튼 이 발표가 지뎃씨에서의 두 번째 발표이자 마지막 발표였는데, 재밌었습니다.

참고자료