강좌와 팁

C++11 최신 문법 요약 정리 날짜:2021-9-12 8:29:10 조회수:58
작성자 : 작가K
포인트 : 1513
가입일 : 2020-02-14 22:27:56
방문횟수 : 213
글 161개, 댓글 49개
소개 : 철들기를 거부하는 개구쟁이 프로그래머
작성글 보기
쪽지 보내기
C/C++ 최신 문법
C++ 표준의 역사
알다시피 C 1972년에 데니스 리치가 만들었고 C++ 80년대 초에 스트로스트룹이 C언어에 객체 지향 개념을 추가하여 만들었다. 초창기 C++ 여러 언어의 기능을 통합하고 세부 문법을 튜닝하는 과정을 거쳐 98년에야 공식 표준안을 확정했다. 이때의 표준안의 공식 명칭이 ISO14882:1998이며 간단히 줄여 C++98이라고 부른다.
최근까지도 C++ 프로그래밍은 주로 C++98 기준으로 하며 지금 나와 있는 대부분의 문법서도 C++98 기반으로 한다. 2000년대를 전후하여 자바, C# 등의 발전된 언어가 등장하여 실무에서 활용 범위를 넓혀 가는 동안에도 C++ 변화 없이 정체되어 있었다. 최신 언어에 비해 개선하기 어려운 구조적인 문제도 있었고 신생 언어로 인해 점유율도 많이 떨어졌다.
2003, 2007 애매한 문법을 확정하고 매뉴얼상의 버그를 보완하며 표준 라이브러리를 정비했지만 눈에 띄는 기능적인 변화는 없었다. 대부분의 개발자도 시기에 새로 도입된 기능은 관심을 두지 않아 여전히 C++98 머무르고 있었다. 사실 이상의 기능이 딱히 필요하지도 않았었다.
그러던중 C++ 2011년에야 이르러 본격적인 문법 정비에 나서기 시작했다. 2000년대 말에 개정 작업에 작성하여 C++0x 불렸으나 결국 장고를 거쳐 2011년에야 표준안을 확정했다. 표준안을 C++11이라고 부른다. 이후에도 C++14, C++17 등의 표준안이 발표되었으나 C++11에서 이미 추가한 기능을 약간씩 확장하고 오래된 기능을 제거하는 수준의 잔손질이이었다.
최근에는 C++20 표준안이 다시 여러 가지 개선 사항을 발표했지만 아직도 진행중이다.  결국 현재로서는 C++11 안정화된 최신 버전인 셈이다. 여기서는 C++98 문법은 이미 안다고 가정하고 C++11 이후에 추가된 문법을 설명한다. 별도의 컴파일러를 따로 준비할 필요 없이 비주얼 스튜디오 2019 모든 실습을 있다.
범위 기반 for 루프
다른 언어의 foreach 해당하는 구문이다. 배열이나 컬렉션을 첨자로 순회하지 않고 요소를 바로 대입받아 하나씩 읽으며 순회한다. 형식은 다음과 같다.

for (타입 제어변수 : 컬렉션) { 제어변수 사용 }

컬렉션은 순회 가능한 모든 것이되 대표적으로 배열이라고 생각하면 된다. 배열 전체를 순회하며 배열 요소를 하나씩 제어 변수에 대입하여 루프 내부로 전달한다. 배열의 일부만 순회하는 경우는 드물며 보통 전체 배열을 순회하는데 구문을 쓰면 간편하다.

#include <stdio.h>

int main()
{
      int ar[] = { 8, 9, 6, 2, 9 };

      for (int i : ar) {
             printf("%d ", i);
      }
}
=> 8 9 6 2 9

정수형 배열 ar 모든 요소를 출력했다. 제어 변수 i 첨자 0, 1, 2, 3, 4 전달받는 것이 아니라 8, 9, 6, 2, 9 요소값을 차례대로 대입받는다. ar 크기나 순회 범위를 지정할 필요 없이 어떤 변수로 요소를 읽겠다는 것만 밝히면 된다.



제어 변수의 타입은 배열 요소와  같은 것이 이상적이다. 정수형 배열 ar 순회하는 제어 변수 i 타입은 int 것이 상식적이다. 그러나 같아야 하는 것은 아니며 호환 타입도 수는 있다. 다만 대입 과정에서 불필요한 형변환이 일어나므로 바람직하지 않다.

for (double i : ar) {
      printf("%f ", i);
}
=> 8.000000 9.000000 6.000000 2.000000 9.000000

루프 내부에서 제어 변수는 배열 요소값을 대입받는 사본이다. 읽기 전용은 아니어서 변경할 수는 있지만 사본을 바꾼다고 해서 원본이 영향을 받지는 않는다.

for (int i : ar) {
      i = i * 2;
}
printf("%d ", ar[0]);
=> 0

루프를 순회하며 ar 모든 요소를 2배로 만들었다. i 실제로 8 대입받아 16 되고 9 대입받아 18 되지만 원본 배열에는 영향을 미치지 않는다. 루프를 ar[0] 출력해 보면 여전히 8이다.
만약 순회중에 원본 배열을 변경해야 한다면 제어 변수를 레퍼런스로 선언한다. 레퍼런스는 사본이 아니라 원본을 가리키는 참조이므로 레퍼런스를 통해 원본 배열을 변경할 있다.

for (int& ri : ar) {
      ri--;
}
for (const int& rri : ar) {
      printf("%d ", rri);
}
=> 7 8 5 1 8

루프를 돌며 모든 배열 요소의 값을 1 감소시켰다. 원본 배열을 다시 출력해 보면 과연 배열 요소값이 1 감소했음을 있다. 원본을 읽기만 한다면 제어 변수를 상수 레퍼런스로 선언한다.
정수형은 상수 레퍼런스를 받는 것보다는 그냥 사본값을 받는 것이 효율적이다. 그러나 거대한 구조체나 객체라면 루프마다 사본을 생성하는 것보다 상수 레퍼런스로 읽는 것이 안전하고 효율적이다.
범위 기반 for 루프도 반복문이므로 루프 내부에서 조건에 따라 break continue 사용할 있다. 다음 코드는 6 건너 뛰고 2 만나면 루프를 탈출한다.

for (int i : ar) {
      if (i == 6) continue;
      printf("%d ", i);
      if (i == 2) break;
}
=> 8 9 2

범위 기반 for 루프는 배열 첨자가 아닌 요소를 대입받아 순회하기 때문에 루프 내부에서 값만 읽을 있을 번째 요소인지는 없다. 만약 순서값까지 알아야 한다면 이때는 전통적인 for 루프를 쓰는 수밖에 없다.

for (int i = 0; i < sizeof(ar) / sizeof(ar[0]); i++) {
      printf("%d번째 요소 = %d\r\n", i, ar[i]);
}

이때 제어 변수 i ar 배열의 첨자를 순회하며 값은 ar[i] 읽는다. for 문의 구문이 길어지는 단점이 있지만 첨자와 요소값을 읽을 있다.

0번째 요소 = 8
1번째 요소 = 9
2번째 요소 = 6
3번째 요소 = 2
4번째 요소 = 9

대개의 경우 순서값보다는 요소값이 필요하므로 범위 기반 for 루프만으로도 배열의 모든 것을 읽을 있다.
auto 타입
auto 키워드는 변수에 대입되는 값을 보고 타입을 자동으로 결정하는 타입이다. 초기식이나 배열 요소의 타입으로부터 타입을 자동 결정할 있다면 auto 키워드만 쓰면 된다.

#include <stdio.h>

int main()
{
      auto i = 1234;
      auto d = 3.14;

      printf("%d\n", i);
      printf("%f\n", d);
}
=> 1234
3.140000 

1234 초기화한 i 누가 봐도 정수형이며 3.14 초기화한 d 누가 봐도 실수형이다. 이럴 int, double 타입을 일일이 밝힐 필요 없이 auto라고만 적어주면 된다. auto 타입이 없는 것이 아니라 자동으로 추론하는 기능이다. 따라서 추론 근거가 있어야 하며 다음과 같이 쓰면 a 타입을 결정할 없어 에러이다.

auto a;       // 에러

auto 유용한 경우는 타입 추론을 직접 하기 귀찮을 때이다. int double 짧아서 어려움이 없지만 템플릿이 끼어들면 요소 타입이 무엇인지 일일이 밝히기 귀찮아진다. 다음 예제를 보자.

#include <stdio.h>
#include <iostream>
#include <vector>
using namespace std;

int main()
{
      const char* str = "korea";
      vector<char> vc(&str[0], &str[5]);
      for (std::vector<char>::iterator it = vc.begin(); it != vc.end();it++) {
             cout << *it;
      }
}

문자형 벡터를 순회하는 반복자의 타입이 std::vector<char>::iterator 되어 있어 너무 길고 복잡하다. 컴파일러가 vc 타입을 알고 있으므로 vc.begin() 어떤 타입을 리턴하는지 추론할 있으며 이때는 다음과 같이 auto 타입만 밝혀 주면 된다.

for (auto it = vc.begin(); it != vc.end(); it++) {
      cout << *it;
}

코드가 짧아졌고 읽기도 쉽다.  템플릿끼리 중첩이 심하면 반복자나 요소의 타입을 일일이 밝히는게 귀찮은 정도가 아니라 어렵고 난해해진다. 이럴 컴파일러에게 " 알아서 " 라고 명령하는 것이 바로 auto이다. 단순한 반복이라면 코드보다는 범위 기반 for문이 짧고 간단하다.

for (char c : vc) {
      cout << c;
}

C++ 14에서는 함수의 리턴 타입에도 auto 키워드를 사용할 있다. 본체에서 어떤 값을 리턴하는지를 보면 리턴 타입도 추론할 있다.  그러나 기능은 득보다 실이 많고 민감한 문제를 일으킬 있어 사용하지 않는 것이 좋다.

auto Add(int a, int b)
{
      return a + b;
}

C++ 이전의 C언어에도 auto 키워드가 있었다. C auto 자동으로 생성되었다가 소멸되는 지역 변수를 의미한다. 자동 변수라고도 불렀으며 외부 변수 external 반대 의미였다. 실제로는 auto 붙이지 않아도 항상 auto 기본이었기 때문에 거의 쓰지 않는 키워드였다. C++ 11에서 auto 의미가 완전히 바뀌어 타입을 자동 추론하라는 뜻이 되었다. 그래서 다음 구문은 에러이다.

auto int i = 1234;

auto 타입이고 int 타입이어서 타입 지정자가 개나 있는 셈이며 따라서 당연히 에러이다. C에서는 구문이 가능했지만 C++에서는 안된다. 아무리 사용 빈도가 떨어지더라고 엄연한 키워드였는데 이런 식으로 용도를 완전히 바꾸어 버리는 것은 흔한 케이스가 아니다.
nullptr
포인터가 비어 있음을 나타내는 리터럴이며 과거의 NULL이나 0 같은 값이다. 포인터가 무효함을 분명히 표현할 있다는 이점이 있다. 예를 들어 다음과 같이 쓴다.

#include <stdio.h>
#include <malloc.h>

int main()
{
      void* mem = nullptr;

      mem = malloc(100);
      if (mem == nullptr) {
             return;
      }
      free(mem);
}

초기화할 때나 비교할 nullptr 쓰면 포인터 비교임을 분명히 있어 가독성이 향상된다. 0이나 NULL 비해 포인터 타입임이 분명해 애매하지 않다는 이점도 있다.

#include <stdio.h>

void func(int a) { printf("%d\n", a); }
void func(int *p) { printf("%p\n", p); }

int main()
{
      func(0);
      func(nullptr);
      func(NULL);         // 애매함
}

정수를 받는 func 함수와 정수형 포인터를 받는 func 함수가 오버로딩되어 있다. 상태에서 func(0) func(nullptr) 어떤 함수를 호출할지는 누가 봐도 명백하다. 그러나 func(NULL) 어떤 함수를 호출할 것인지는 정확하게 추측하기 어렵다.
NULL 언어에 따라 정의가 달라지는데 C++에서는 0이며 C에서는 (void *)0으로 정의되어 있어 결과가 일관되지도 못하다. 이런 미세한 차이점이 없는 버그의 원흉이 되기도 한다.
과거의 호환성을 유지해야 하므로 앞으로도 NULL이나 0 여전히 사용할 있다. 그러나 포인터에 대한 표현은 가급적 nullptr 표기하는 것이 가독성에 유리하다.
열거형 클래스
C 열거형인 enum 열거 상수의 범위가 전역이어서 불편한 면이 많다. 다른 타입의 얼거형끼리도 열거 상수가 중복되어서는 안되며 서로 값을 대입할 있어 혼란스럽다.

enum Origin { EAST, WEST, SOUTH, NORTH };
enum Race { Terran, Protoss, Zerg };
int o = SOUTH;
o = Zerg;
printf("%d", o);            // 2

방향을 구분하는 Origin 열거형과 종족을 구분하는 Race 열거형을 선언했다. 남쪽을 의미하는 SOUTH 저그 종족을 의미하는 Zerg 2 값을 가지며 정수형 변수에 이상없이 대입할 있다. 이러다 보니 SOUTH라고 적어야 부분에 Zerg 적어도 에러가 발생하지 않아 잠재적으로 위험하다.
열거 상수가 전역의 특성을 가지므로 어디서나 이름만으로 사용할 있어 간편하지만 열거형끼리 상수가 중복되어서는 안된다는 제약도 있다. 다음 선언문은 에러이다. EAST Origin 0으로 이미 정의되어 있는데 Race에서 3으로 다시 정의하려고 했으니 중복 선언이다.

enum Race { Terran, Protoss, Zerg, EAST };

거대 프로젝트에서 여기 저기서 열거형을 많이 사용하면 명칭 중복이 발생할 확률이 높다. 이런 문제를 방지하기 위해 열거 상수마다 접두를 붙여 oEast, rTerran 따위로 고유성을 부여해야 하는데 여러 모로 불편하다. 이런 불편함을 해소하기 위해 enum class 도입했다.

- 열거 상수는 열거 클래스에 소속되며 전역이 아니어서 다른 열거형과 명칭이 중복되도 상관 없다.
- 열거 상수를 칭할 때는 열거형::상수 식으로 칭한다.
- 정수형과 호환되지 않아 캐스팅을 해야만 대입할 있다.

C 열거형은 범위가 없는데 비해 C++ 열거 클래스는 범위가 있다는 것이 가장 차이점이다. 방향 열거형을 클래스로 정의하면 다음과 같다. 어차피 소속을 밝히고 쓰므로 대문자로 쓰지 않아도 상관 없다.

enum class EOrigin { east, west, south, north };
int o = (int)EOrigin::south;
printf("%d", o);            // 2

enum 키워드 다음에 class 키워드를 추가하여 선언한다. 이렇게 되면 열거 상수를 칭할 단독으로는 없고 EOrigin::south 식으로 소속을 반드시 밝혀야 한다. 또한 정수형과 호환되지 않아 대입할 때는 강제로 캐스팅해야 하며 실수를 줄여 준다.
열거형은 기본적으로 int 타입과 크기가 같아 열거 상수 하나가 4바이트를 차지한다. 40억개의 가지수를 구분하는 정수형은 열거형으로 쓰기에는 너무 크다. 열거 상수의 가지수가 많지 않을 때는 베이스 타입을 지정하여 메모리를 조금이나마 절약할 있다.

enum class EOrigin : char { east, west, south, north };
printf("%d", sizeof(EOrigin::south));       // 1

EOrigin 타입을 char 지정했다. char 타입만 해도 256가지의 상태를 구분할 있다. sizeof 열거 상수의 크기를 출력해 보면 1바이트임을 있다. 메모리를 알뜰하게 있다는 이점이 있지만 별로 권할만하지 않다. 정수는 32비트가 가장 효율적으로 동작하며 크기를 줄이면 오히려 속도가 떨어진다.
열거 클래스는 열거형보다는 문법적으로 향상되었지만 그렇다고 해서 C enum 정도는 아니다. 열거형이 많지 않고 간단한 용도로 쓴다면 오히려 열거 클래스가 장황하고 걸리적거린다. 열거 클래스가 열거형을 완전히 대체한다기보다는 필요에 따라 적당한 것을 쓰는 것이 바람직하다.
람다
람다는 함수를 간략하게 표현하는 단축 표기법이다. C#이나 자바에는 벌써 도입되었지만 C++에서는 다소 늦게 도입되었다. 선언 형식은 다음과 같다.

[캡처](인수)->리턴타입 { 본체; }

결국 함수 선언문에 있는 요소는 거의 포함되어 있는 셈이다. 간단하게 정수를 더하는 함수를 람다로 작성해 보자.

int (*add)(int, int) = [](int a, int b)->int {return a + b; };
printf("%d\n", add(2, 3));       // 5

a, b 개의 정수형을 받아 정수의 합을 int 타입으로 리턴하는 람다식을 정의하고 값을 함수 포인터 add 대입했다. 대입문이기 때문에 제일 뒤에도 세미콜론이 필요하다.
add 개의 정수를 받아 정수를 리턴하는 함수 포인터이다. 이후 add 함수처럼 사용할 있다. 함수 포인터 타입 정의가 어려우면 자리에 그냥 auto라고 쓰면 컴파일러가 타입을 추론해 준다.

auto add = [](int a, int b)->int {return a + b; };

람다는 최대한 짧게 쓰기 위해 도입한 것이어서 여러 요소를 생략할 있다. 구문에서 리턴 타입은 생략 가능하다.

auto add = [](int a, int b) {return a + b; };

C#이나 자바에 비해 인수의 타입이나 return 키워드는 생략할 없다. 인수가 없으면 인수 목록의 괄호를 생략할 있고 리턴 타입이 없는 void 함수라면 return 키워드도 생략할 있다.  다음은 인수도 없고 리턴도 없는 단순 출력문을 람다로 정의하여 호출까지 하는 코드이다.

[] { printf("Hello C++");  }();

람다가 일반 함수와 다른 점은 정의문을 바로 인라인화하여 수식에 집어 넣을 있다는 점이다. printf 인수열에서 람다를 생성하고 인수까지 넘겨 호출해 버리면 된다.

printf("%d\n", [](int a, int b)->int {return a + b; }(2, 3));

캡처는 람다 외부의 변수를 람다식에서 참조할 람다식 실행 주기동안은 해당 변수의 값을 유지해주는 기능이다. 다음 예를 보자.
 
#include <stdio.h>

auto getfunc()
{
      int base = 1;
      auto add = [base](int a, int b) {return a + b + base; };
      return add;
}

int main()
{
      auto add = getfunc();
      printf("%d\n", add(2, 3));       // 6
}

base getfunc 지역 변수이며 람다식 내부에서 참조하고 있다. 람다도 getfunc 내부에 있지만 람다가 실행될 base 남아 있다고 보장할 수는 없다. 그래서 캡처 구문에 [base] 명시하여 람다식이 실행되는 동안에는 base 유효하도록 보장해 주어야 한다.
main에서 getfunc 호출하여 람다식 add 구하고 add 통해 2, 3 그리고 base까지 더해 6 계산했다. base getfunc 지역 변수이지만 main에서 add 호출할 때까지 값을 유지하고 있음을 있다. 캡처는 해당 변수가 범위를 벗어나더라도 람다식 내에서 유효하도록 잡아 두는 기능이다.
[ ] 괄호안에 캡처할 변수 목록을 적되 = 기호를 쓰면 모든 외부 변수의 사본을 복사하고 & 기호를 쓰면 모든 외부 변수의 참조를 복사한다. 이벤트 핸들러를 람다로 정의할 정의 시점의 지역 변수값을 핸들러에서 읽어야 캡처 기능을 사용한다.
 



돈 못 벌어도 좋다. 즐겁게 살면 된다.

목록보기 삭제 수정 신고 스크랩


로그인하셔야 댓글을 달 수 있습니다.