LearnCPP - 8

Justin·2026년 2월 16일

LearnCPP.com

목록 보기
8/22

제어 흐름(Control flow) 소개

  • CPU가 실행하는 구체적인 구문들의 순서를 프로그램의 실행 경로 또는 짧게 경로라고 부릅니다.
  • C++은 정상적인 실행 경로를 프로그래머가 마음대로 바꿀 수 있도록 도와주는 다양한 제어 흐름 구문을 제공하고 있어요.
  • 이처럼 제어 흐름 구문이 실행 지점을 순차적이지 않은 다른 구문으로 이동시키는 것을 분기라고 부른답니다.

흐름 제어 구문의 분류 (Categories of flow control statements)

분류 (Category)의미 (Meaning)C++ 구현 형태 (Implemented in C++ by)
조건문 (Conditional statements)특정 조건(Condition)이 충족될 때만 일련의 코드를 실행하도록 합니다.if, else, switch
점프 (Jumps)CPU에게 다른 위치에 있는 구문부터 실행을 시작하도록 지시합니다.goto, break, continue
함수 호출 (Function calls)다른 위치로 점프하여 실행한 뒤, 다시 원래 위치로 돌아옵니다.함수 호출, return
반복문 (Loops)특정 조건이 충족될 때까지, 일련의 코드를 0번 이상 반복해서 실행합니다.while, do-while, for, ranged-for
중단 (Halts)프로그램을 완전히 종료시킵니다.std::exit(), std::abort()
예외 (Exceptions)오류 처리(Error handling)를 위해 특별히 설계된 흐름 제어 구조입니다.try, throw, catch

Constexpr if문 (C++17 도입)

  • constexpr if문의 가장 큰 특징은 조건식이 프로그램 실행 중이 아니라 컴파일 타임에 평가된다는 점이에요.
  • 만약 constexpr 조건식이 true로 평가되면, if-else전체 구조가 '참일 때 실행되는 문장'으로 완전히 대체됩니다.
  • 반대로 false로 평가되면, if-else문 전체가 '거짓일 때 실행되는 문장'으로 대체되거나,
    else문이 아예 없는 경우에는 아무것도 없는 상태로 깔끔하게 사라지게 됩니다.
  • constexpr if문을 사용하는 방법은 아주 간단해요. if 키워드 바로 뒤에 constexpr 키워드를 붙여주기만 하면 됩니다.
#include <iostream>

int main()
{
	constexpr double gravity{ 9.8 };

	if constexpr (gravity == 9.8) // 이제 constexpr if를 사용합니다.
		std::cout << "Gravity is normal.\n";
	else
		std::cout << "We are not on Earth.\n";

	return 0;
}
  • 위 코드가 컴파일될 때, 컴파일러는 컴파일 타임에 조건식을 미리 평가합니다.
  • 그리고 그 조건이 항상 true라는 것을 확인한 뒤, 불필요한 부분은 지우고 std::cout << "Gravity is normal.\n";라는 단 하나의 문장만 남겨두게 됩니다.
  • 조건식이 상수 표현식인 경우에는 일반 if문보다 constexpr if문을 사용하는 것을 강력히 권장합니다.
int main()
{
	constexpr double gravity{ 9.8 };

	std::cout << "Gravity is normal.\n";

	return 0;
}

switch 문(Switch statement) 기초

  • 어떤 변수나 표현식이 여러 다른 값들과 같은지 검사하는 일은 프로그래밍에서 아주 흔하게 발생합니다.
  • 그래서 C++은 이런 목적에 딱 맞게 특화된 switch 문이라는 대안적인 조건문을 제공한답니다.
#include <iostream>

void printDigitName(int x)
{
    switch (x)
    {
    case 1:
        std::cout << "One";
        return;
    case 2:
        std::cout << "Two";
        return;
    case 3:
        std::cout << "Three";
        return;
    default:
        std::cout << "Unknown";
        return;
    }
}

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

    return 0;
}
  • switch 문의 기본 원리는 아주 간단해요.
  • 먼저 하나의 표현식(때로는 '조건'이라고도 부릅니다)을 평가해서 결과 값을 만들어 냅니다.
  • 그런 다음 아래의 세 가지 경우 중 하나가 발생합니다.
      1. 표현식의 값이 어떤 case 라벨뒤에 있는 값 중 하나와 같다면, 일치하는 case 라벨 이후의 문장들이 실행됩니다.
      1. 일치하는 값을 찾지 못했는데 default 라벨이 존재한다면, default 라벨 이후의 문장들이 실행됩니다
      1. 일치하는 값도 찾지 못했고 default 라벨도 없다면, switch 문은 통째로 건너뛰게 됩니다.

switch 문 시작하기

  • switch 문을 시작할 때는 switch 키워드를 사용하고, 그 뒤에 평가하고 싶은 조건 표현식을 괄호 안에 넣어줍니다.
  • 보통 이 표현식은 단일 변수인 경우가 많지만, 유효한 표현식이라면 무엇이든 들어갈 수 있어요.
    • switch 문의 조건은 반드시 정수형으로 평가되거나 열거형이어야 하며, 혹은 이러한 타입으로 변환될 수 있어야 합니다.

case 라벨 (Case labels)

  • 첫 번째 라벨의 종류는 바로 case 라벨입니다. case 키워드를 사용해서 선언하고, 그 뒤에 상수 표현식이 따라옵니다.
  • 이 상수 표현식은 조건의 타입과 반드시 일치하거나 그 타입으로 변환될 수 있어야 해요.
  • 조건 표현식의 값이 어떤 case 라벨 뒤의 표현식과 같다면, 해당 case 라벨 바로 다음 문장부터 실행이 시작되어 순차적으로 진행됩니다.
  • 사용할 수 있는 case 라벨의 개수에는 실질적인 제한이 없습니다.
  • 하지만 switch 문 내의 모든 case 라벨은 고유해야 합니다. 즉, 중복해서 작성하면 안 됩니다.
#include <iostream>

void printDigitName(int x)
{
    switch (x) // x를 평가하여 값 2를 얻어냅니다
    {
    case 1:
        std::cout << "One";
        return;
    case 2: // 이곳의 case 문과 일치하네요!
        std::cout << "Two"; // 그래서 여기서부터 실행이 시작됩니다
        return; // 그리고 함수를 호출했던 곳으로 돌아갑니다(return)
    case 3:
        std::cout << "Three";
        return;
    default:
        std::cout << "Unknown";
        return;
    }
}

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

    return 0;
}

default 라벨 (The default label)

  • 두 번째 라벨의 종류는 default 라벨이에요.
  • default 키워드를 사용해서 선언합니다.
  • 조건 표현식이 어떤 case 라벨과도 일치하지 않을 때, 만약 default 라벨이 존재한다면 default 라벨 바로 다음 문장부터 실행이 시작된답니다.
#include <iostream>

void printDigitName(int x)
{
    switch (x) // x를 평가하여 값 5를 얻어냅니다
    {
    case 1:
        std::cout << "One";
        return;
    case 2:
        std::cout << "Two";
        return;
    case 3:
        std::cout << "Three";
        return;
    default: // 어떤 case 라벨과도 일치하지 않으므로
        std::cout << "Unknown"; // 여기서부터 실행이 시작됩니다
        return; // 그리고 함수를 호출했던 곳으로 돌아갑니다(return)
    }
}

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

    return 0;
}

일치하는 case 라벨도 없고 default case도 없는 경우

  • 만약 조건 표현식의 값이 어떤 case 라벨과도 일치하지 않고, 제공된 default case 마저 없다면, switch 문 내부의 어떤 케이스도 실행되지 않습니다.
  • 대신 실행 흐름은 switch 블록이 끝난 바로 다음 문장부터 계속 이어지게 됩니다.

break 문(Break statement)

  • 위의 예제들에서는 라벨 아래 문장들의 실행을 멈추기 위해 return 문을 사용했어요. 하지만 return 문은 함수 전체를 빠져나가게 만든다는 특징이 있죠.
  • break 문은 컴파일러에게 "switch 문 내부의 실행이 끝났으니, switch 블록 다음 문장부터 실행을 계속해라"라고 알려주는 역할을 합니다.
  • 즉, 함수 전체를 종료하지 않고도 switch 문만 깔끔하게 빠져나올 수 있게 해주는 것이죠!

폴스루(Fallthrough)

  • switch문case 라벨이나 선택 사항인 default 라벨과 일치하면, 일치하는 라벨 바로 다음 명령문부터 실행이 시작됩니다.
  • 그리고 break return과 같은 종료 조건 중 하나가 발생할 때까지 실행은 순차적으로 계속 진행돼요.
  • 여기서 꼭 기억해야 할 점은, 다른 case 레이블이 나타나는 것은 종료 조건이 아니라는 것입니다.
  • 따라서 breakreturn이 없으면 실행 흐름은 그다음 case들로 계속 넘쳐흘러 가게(overflow) 됩니다.
  • 특정 레이블 아래의 명령문에서 실행 흐름이 그다음 레이블 아래의 명령문으로 계속 흘러가는 현상을 폴스루(fallthrough) 라고 부릅니다.

[[fallthrough]] 속성(Attribute)

  • 폴스루가 의도한 것이거나 유용하게 쓰이는 경우는 드물기 때문에, 많은 컴파일러와 코드 분석 도구들은 폴스루가 발생하면 경고를 띄워 알려줍니다.
  • 이를 해결하기 위해 C++17에서는 [[fallthrough]]라는 새로운 속성이 추가되었습니다.
  • [[fallthrough]] 속성은 널 명령문을 수식하여 폴스루가 의도적임을 나타냅니다.
#include <iostream>

int main()
{
    switch (2)
    {
    case 1:
        std::cout << 1 << '\n';
        break;
    case 2:
        std::cout << 2 << '\n'; // 여기서부터 실행이 시작됩니다.
        [[fallthrough]]; // 의도된 폴스루 -- 널 명령문을 나타내는 세미콜론(;)에 주목하세요.
    case 3:
        std::cout << 3 << '\n'; // 이 줄도 실행됩니다.
        break;
    }

    return 0;
}

case 문 내부에서의 변수 선언과 초기화

  • 스위치문의 경우, 레이블 뒤에 오는 명령문들은 모두 전체 스위치 블록에 스코프가 종속됩니다.
  • 각각의 case마다 암시적인 블록이 따로 만들어지지 않아요.
  • 아래 예제에서 case 1default 레이블 사이에 있는 두 개의 명령문은 case 1에 속한 개별 블록이 아니라,
    스위치 블록 전체 범위의 일부로 취급됩니다.
switch (1)
{
case 1: // 암시적 블록을 생성하지 않습니다.
    foo(); // 이 부분은 case 1의 암시적 블록이 아니라, 스위치 전체 스코프의 일부입니다.
    break; // 이 부분은 case 1의 암시적 블록이 아니라, 스위치 전체 스코프의 일부입니다.
default:
    std::cout << "default case\n";
    break;
}
  • 이러한 스코프 규칙 때문에, 변수를 선언할 때 주의해야 할 점이 있습니다.
  • 스위치문 내부에서는 case 레이블 이전이든 이후이든 변수를 선언하거나 정의할 수 있습니다.
  • 하지만 초기화는 조심해야 합니다.
switch (1)
{
    int a; // 허용됨: case 레이블 이전의 정의는 허용됩니다.
    int b{ 5 }; // 오류(Illegal): case 레이블 이전의 초기화는 허용되지 않습니다.

case 1:
    int y; // 허용되지만 나쁜 관행: case 내부의 정의는 허용됩니다.
    y = 4; // 허용됨: 값을 할당(Assignment)하는 것은 허용됩니다.
    break;

case 2:
    int z{ 4 }; // 오류(Illegal): 뒤에 다른 case가 존재한다면 초기화는 허용되지 않습니다.
    y = 5; // 허용됨: y는 위(case 1)에서 선언되었으므로 여기서도 사용할 수 있습니다.
    break;

case 3:
    break;
}
  • 변수 ycase 1에서 정의되었지만, case 2에서도 문제없이 사용되었습니다.
  • switch의 모든 case는 같은 스코프라서, 한 case에서 선언한 변수는 다른 case에서도 보입니다.
  • 하지만 초기화는 실행이 필요하므로, 다른 case로 점프하면서 초기화를 건너뛸 수 있는 위치에서는 금지됩니다.
  • 특정 case에서만 변수 선언+초기화를 하려면 {} 블록을 만들어 그 안에서 해야 안전합니다.
switch (1)
{
case 1:
{ // 여기에 명시적인 블록을 추가한 것을 확인하세요.
    int x{ 4 }; // 허용됨: case 내부의 명시적 블록 안에서는 변수를 초기화할 수 있습니다.
    std::cout << x;
    break;
}
default:
    std::cout << "default case\n";
    break;
}

Goto 문 (Goto statements)

  • 이번에 다룰 제어 흐름 구문은 바로 무조건 점프입니다.
  • 표현식의 결과에 따라 조건부로 점프가 일어나는 if 문이나 switch 문과는 다르게 무조건 점프는 프로그램의 실행 흐름을 코드의 다른 위치로 곧바로 건너뛰게 만들어 줍니다.
  • C++에서 이러한 무조건 점프는 goto 문을 통해 구현됩니다.
  • 그리고 어디로 점프할지 그 도착 지점은 구문 레이블(Statement label)을 사용해서 지정해 줍니다.
#include <iostream>
#include <cmath> // sqrt() 함수를 사용하기 위해 포함합니다.

int main()
{
    double x{};
tryAgain: // 이것이 바로 구문 레이블(statement label)입니다.
    std::cout << "Enter a non-negative number: ";
    std::cin >> x;

    if (x < 0.0)
        goto tryAgain; // 이것이 바로 goto 문입니다.

    std::cout << "The square root of " << x << " is " << std::sqrt(x) << '\n';
    return 0;
}

구문 레이블은 함수 범위(Function scope)를 가집니다

  • 객체의 범위를 다루었던 7 챕터에서 우리는 지역 범위파일 범위라는 두 가지 범위를 배웠어요.
  • 구문 레이블은 세 번째 종류의 범위인 함수 범위(Function scope)를 사용합니다.
  • 이는 해당 레이블이 선언되기 전이라도 함수 전체에서 그 레이블을 볼 수 있다는 뜻입니다.
  • 단, goto 문과 그에 연결된 구문 레이블은 반드시 같은 함수 안에 있어야 합니다.
  • 앞으로 점프할 때, 점프 도착 지점에서도 여전히 범위 내에 있는 변수의 초기화 과정을 건너뛰어 점프할 수는 없습니다.
int main()
{
    goto skip;   // 오류: 이 점프는 허용되지 않습니다. 왜냐하면...
    int x { 5 }; // 이 초기화된 변수가 'skip' 구문 레이블 위치에서도 여전히 범위 내에 있기 때문입니다.
skip:
    x += 3;      // 만약 x가 초기화되지 않았다면 이 코드는 대체 어떻게 평가될까요?
    return 0;
}

while 문 (While statements)

  • while 문은 C++이 제공하는 세 가지 종류의 반복문 중 가장 단순한 형태이며, if 문과 아주 비슷한 구조를 가지고 있습니다.
while (조건식)
    명령문;
  • while 문은 while이라는 키워드를 사용해서 선언해요.
  • while 문이 실행되면 가장 먼저 조건식을 평가합니다.
  • 만약 조건식이 으로 평가되면, 그 아래에 연결된 명령문이 실행됩니다.

정수형 루프 변수는 부호 있는(signed) 타입이어야 합니다.

  • 정수형 루프 변수는 거의 항상 부호 있는(signed) 타입을 사용해야 합니다.
  • 부호 없는(unsigned) 정수를 사용하면 전혀 예상치 못한 문제가 발생할 수 있거든요.
  • 다음 코드를 함께 볼까요?
#include <iostream>

int main()
{
    unsigned int count{ 10 }; // 주의: unsigned (부호 없는 정수)로 선언했습니다.

    // 10부터 0까지 카운트다운 합니다.
    while (count >= 0)
    {
        if (count == 0)
        {
            std::cout << "blastoff!";
        }
        else
        {
            std::cout << count << ' ';
        }
        --count;
    }

    std::cout << '\n';

    return 0;
}
  • 처음에는 우리가 원했던 대로 10 9 8 7 6 5 4 3 2 1 blastoff!라고 잘 출력합니다.
  • 하지만 그다음 순간 루프 변수 count오버플로우가 발생하면서, 4294967295부터 다시 카운트다운을 시작해 버립니다.

for 문 (For statements)

  • C++에서 단연코 가장 많이 사용되는 반복문은 바로 for 문입니다.
  • 반복을 제어하는 명확한 반복 변수(Loop variable)가 있을 때 for 문을 사용하는 것이 가장 좋습니다.
  • 반복 변수를 정의하고, 초기화하고, 검사하고, 변경하는 모든 과정을 아주 쉽고 간결하게 한곳에 모아둘 수 있기 때문이죠.
  • C++11부터는 두 가지 종류의 for 문이 존재해요. 이번 강의에서는 전통적인 클래식 for 문을 다룹니다.

for 문의 평가 과정 (Evaluation of for-statements)

for (init-statement; condition; end-expression)
   statement;
  • for 문은 크게 3단계로 나뉘어 실행됩니다.

    1. 초기화 명령문(init-statement)이 실행됩니다.
      이 과정은 반복문이 처음 시작될 때 단 한 번만 일어납니다.
      주로 변수를 정의하고 초기화하는 데 사용돼요.
      여기서 만들어진 변수들은 '반복문 스코프(Loop scope)'를 가지게 되는데,
      쉽게 말해 이 변수들은 변수가 정의된 시점부터 반복문이 끝날 때까지만 존재한다는 뜻입니다.
    1. 매 반복마다 조건식을 평가합니다.
      만약 조건식이 참(true)이라면 내부의 명령문(statement)이 실행됩니다.
      반대로 거짓(false)이라면 반복문은 즉시 종료되고, 코드의 실행 흐름은 반복문 바로 다음 줄로 넘어갑니다.
    1. 명령문이 실행된 후 끝 표현식(end-expression)이 평가됩니다.
      보통 이 자리에는 초기화 단계에서 만든 반복 변수를 1씩 증가시키거나 ++ 감소시키는 -- 코드가 들어갑니다.
      끝 표현식의 실행이 끝나면, 다시 두 번째 단계로 돌아가서 조건식을 재평가합니다.

for 문의 각 부분이 실행되는 순서를 정확히 기억해 두는 것이 매우 중요합니다.
1. 초기화 명령문 (Init-statement)
2. 조건식 (Condition) (만약 여기서 거짓이 나오면 반복문은 바로 끝납니다.)
3. 반복문 본문 (Loop body)
4. 끝 표현식 (End-expression) (실행 후 다시 2번 조건식으로 돌아갑니다.)

#include <iostream>

int main()
{
    for (int i{ 1 }; i <= 10; ++i)
        std::cout << i << ' ';

    std::cout << '\n';

    return 0;
}

Break (강제 종료하기)

  • break 문while 루프 do-while 루프 for 루프(반복문) 혹은 switch 문을 즉시 종료시키는 역할을 합니다.
  • break로 인해 해당 블록을 빠져나오면, 바로 그다음 줄의 코드부터 실행이 계속됩니다.

switch 문에서 빠져나오기 (Breaking a switch)

  • switch 문의 문맥에서 break는 보통 각 case의 끝에 사용되어 해당 case가 끝났음을 알립니다.
  • 이를 통해 다음 case로 의도치 않게 넘어가 버리는 폴스루 현상을 방지할 수 있어요.
#include <iostream>

void printMath(int x, int y, char ch)
{
    switch (ch)
    {
    case '+':
        std::cout << x << " + " << y << " = " << x + y << '\n';
        break; // 다음 case로 넘어가지 않도록(fall-through) 방지해요
    case '-':
        std::cout << x << " - " << y << " = " << x - y << '\n';
        break; // 다음 case로 넘어가지 않도록 방지해요
    case '*':
        std::cout << x << " * " << y << " = " << x * y << '\n';
        break; // 다음 case로 넘어가지 않도록 방지해요
    case '/':
        std::cout << x << " / " << y << " = " << x / y << '\n';
        break;
    }
}

int main()
{
    printMath(2, 3, '+');

    return 0;
}

루프에서 빠져나오기 (Breaking a loop)

  • 반복문 내에서 break 문을 사용하면 루프를 일찍 종료할 수 있습니다.
  • 루프가 종료된 후에는 루프 바로 다음 문장부터 실행이 이어집니다.
#include <iostream>

int main()
{
    int sum{ 0 };

    // 사용자가 최대 10개의 숫자를 입력할 수 있도록 허용해요
    for (int count{ 0 }; count < 10; ++count)
    {
        std::cout << "Enter a number to add, or 0 to exit: ";
        int num{};
        std::cin >> num;

        // 사용자가 0을 입력하면 루프를 종료해요
        if (num == 0)
            break; // 지금 바로 루프를 빠져나갑니다

        // 그렇지 않으면 입력한 숫자를 합계에 더해요
        sum += num;
    }

    // break로 루프를 빠져나오면 여기서부터 실행이 계속됩니다
    std::cout << "The sum of all the numbers you entered is: " << sum << '\n';

    return 0;
}

Break vs Return (어떤 차이가 있을까요?)

  • break 문현재 실행 중인 switch반복문만 종료시키고, 그 바로 바깥쪽의 다음 코드를 계속 실행합니다.
  • 반면 return 문은 해당 루프가 들어있는 함수 전체를 완전히 종료시키고, 그 함수를 호출했던 위치로 되돌아갑니다.

Continue (이번 순서는 건너뛰기)

  • continue 문은 전체 루프를 끝내지 않으면서도, 현재 진행 중인 반복(iteration, 루프의 한 사이클)만 깔끔하게 종료하고 다음 반복으로 넘어가게 해주는 편리한 방법이에요.
#include <iostream>

int main()
{
    for (int count{ 0 }; count < 10; ++count)
    {
        // 만약 숫자가 4로 나누어 떨어지면 이번 반복은 건너뜁니다
        if ((count % 4) == 0)
            continue; // 다음 반복(iteration)으로 이동해요

        // 숫자가 4로 나누어 떨어지지 않으면 계속 진행합니다
        std::cout << count << '\n';

        // continue 문이 실행되면 바로 이 위치(루프의 끝)로 점프하게 됩니다
    }

    return 0;
}

이 프로그램은 0부터 9까지의 숫자 중 4로 나누어 떨어지지 않는 숫자만 출력합니다:

1
2
3
5
6
7
9

중단(Halts) (프로그램 일찍 종료하기)

  • 이번 장에서 다룰 마지막 제어 흐름문은 바로 중단(Halt)입니다.
  • 중단이란 프로그램을 완전히 종료시키는 제어 흐름문을 말해요.
  • C++에서 중단은 키워드가 아닌 함수 형태로 구현되어 있기 때문에, 중단문은 곧 함수 호출(Function call) 형태를 띠게 된답니다.
  • 프로그램이 정상적으로 종료될 때 어떤 일이 일어나는지 잠깐 복습해 볼까요?
  • 첫째, 함수를 빠져나가기 때문에 평소처럼 모든 지역 변수와 함수 매개변수가 소멸됩니다.
  • 그다음, main() 함수의 반환값 상태 코드(Status code)를 인자로 삼아 std::exit()라는 특수한 함수가 호출된답니다.
  • 그렇다면 이 std::exit()는 과연 무엇일까요?

std::exit() 함수

  • std::exit()는 프로그램을 정상 종료시키는 함수예요.
  • 여기서 '정상 종료'란 프로그램이 우리가 예상한 방식대로 종료되었다는 뜻입니다.
  • 참고로 정상 종료라는 말이 프로그램이 완벽하게 '성공적으로' 수행되었음을 의미하는 것은 아니에요.
  • 예를 들어, 사용자가 입력한 파일명으로 작업을 처리하는 프로그램을 만들었다고 해볼게요.
  • 만약 사용자가 잘못된 파일명을 입력했다면, 프로그램은 실패 상태를 알리기 위해 0이 아닌 상태 코드를 반환하겠지만,
    이 역시 프로그램 입장에서는 예상된 흐름이므로 여전히 '정상 종료'에 해당합니다.
  • std::exit()는 여러 가지 정리(Cleanup) 작업을 수행해요.
    1. 먼저 정적 저장 주기(Static storage duration)를 가진 객체들을 소멸시킵니다.
    1. 사용 중인 파일이 있다면 기타 파일 정리 작업을 수행하죠.
    1. 마지막으로, std::exit()전달된 인자를 상태 코드로 사용하여 운영체제(OS)에 제어권을 돌려준답니다.

명시적으로 std::exit() 호출하기

  • main() 함수가 끝난 뒤에 std::exit()가 암시적으로 자동 호출되긴 하지만, 프로그램이 원래 끝나야 할 시점보다 일찍 프로그램을 멈추기 위해 명시적으로 직접 호출할 수도 있어요. 이렇게 사용할 때는 반드시 <cstdlib> 헤더를 포함(Include)해야 합니다.
#include <cstdlib> // std::exit()를 사용하기 위해 포함
#include <iostream>

void cleanup()
{
    // 필요한 모든 종류의 정리 작업을 수행하는 코드를 여기에 작성합니다
    std::cout << "cleanup!\n";
}

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

    std::exit(0); // 프로그램을 종료하고 운영체제에 상태 코드 0을 반환합니다.

    // 아래의 명령문들은 프로그램이 이미 종료되었으므로 절대 실행되지 않습니다.
    std::cout << 2 << '\n';

    return 0;
}
  • 위 예제에서는 main() 함수 안에서 std::exit()를 호출했지만, 사실 std::exit()어떤 함수에서든 호출할 수 있고, 호출된 그 시점에서 즉시 프로그램을 종료시킨답니다.

std::exit()는 지역 변수를 정리하지 않습니다

  • std::exit()를 직접 호출할 때 아주 중요한 주의 사항이 하나 있어요.
  • 바로 std::exit()현재 함수나 호출 스택 위쪽의 어떤 지역 변수도 정리하지 않는다는 점이에요.
  • 즉, 프로그램이 지역 변수들이 스스로 잘 정리될 것이라 믿고 작동하게끔 설계되었다면, std::exit()를 호출하는 것은 꽤 위험할 수 있습니다.

std::atexit

  • std::exit()는 프로그램을 즉시 종료시키기 때문에, 종료되기 전에 수동으로 어떤 정리 작업을 하고 싶을 수 있어요.
  • 여기서 '정리'란 데이터베이스나 네트워크 연결을 닫는 것, 할당받은 메모리를 해제하는 것, 로그 파일에 정보를 기록하는 것 등을 말한답니다.
  • 앞선 예제에서는 이 정리 작업을 처리하기 위해 우리가 직접 cleanup()이라는 함수를 호출했어요.
  • 하지만 exit()를 호출할 때마다 매번 수동으로 정리 함수 부르는 것을 기억해야 한다면, 프로그래머에게 큰 부담이 되고 오류가 생기기 딱 좋은 환경이 됩니다.
  • 이를 돕기 위해 C++은 std::atexit()라는 함수를 제공합니다.
  • 이 함수를 사용하면, std::exit()를 통해 프로그램이 종료될 때 자동으로 호출될 함수를 미리 지정해 둘 수 있어요.
#include <cstdlib> // std::exit()를 사용하기 위해 포함
#include <iostream>

void cleanup()
{
    // 필요한 모든 종류의 정리 작업을 수행하는 코드를 여기에 작성합니다
    std::cout << "cleanup!\n";
}

int main()
{
    // std::exit()가 호출될 때 자동으로 호출되도록 cleanup()을 등록합니다.
    std::atexit(cleanup); // 참고: 지금 당장 cleanup() 함수를 실행하는 것이 아니기 때문에 괄호를 빼고 'cleanup'이라고만 적습니다.

    std::cout << 1 << '\n';

    std::exit(0); // 프로그램을 종료하고 운영체제에 상태 코드 0을 반환합니다.

    // 아래의 명령문들은 절대 실행되지 않습니다.
    std::cout << 2 << '\n';

    return 0;
}
  • 여기서 주목할 점은, cleanup() 함수를 인자로 넘겨줄 때 괄호를 붙인 cleanup()(이것은 함수를 즉시 호출해 버립니다)이 아니라, 함수의 이름인 cleanup만 사용했다는 점입니다.

std::atexit()와 이 정리 함수에 대해 몇 가지 알아둘 점이 있습니다.

    1. main() 함수가 끝날 때 암시적으로 std::exit()가 호출되므로, return으로 프로그램이 끝나는 경우에도 std::atexit()로 등록한 함수들이 똑같이 실행됩니다.
    1. 등록되는 함수는 매개변수가 없어야 하고, 반환값(Return value)도 없어야(void) 합니다.
    1. 원한다면 std::atexit()를 여러 번 써서 여러 개의 정리 함수를 등록할 수도 있어요.
  • 이때 함수들은 등록된 역순(Reverse order)으로 호출됩니다. (즉, 가장 마지막에 등록된 것이 제일 먼저 실행돼요.)

고급 독자를 위한 내용 (For advanced readers)

  • 멀티스레드 프로그램에서 std::exit()를 호출하면 프로그램이 강제 종료될 수 있습니다.
  • std::exit()를 호출한 스레드가 다른 스레드에서 여전히 사용 중일지 모르는 정적 객체들을 파괴해 버리기 때문이에요.
  • 이러한 이유로, C++은 std::exit()std::atexit()와 비슷하게 작동하는 std::quick_exit()std::at_quick_exit()라는 또 다른 함수 쌍을 도입했습니다.
  • std::quick_exit()는 프로그램을 정상적으로 종료하긴 하지만, 정적 객체들을 정리하지 않으며, 다른 종류의 정리 작업도 수행할지 안 할지 보장하지 않습니다.
  • std::at_quick_exit()std::quick_exit()로 종료되는 프로그램에서 std::atexit()와 같은 역할을 수행합니다.

std::abort와 std::terminate

  • std::abort() 함수는 프로그램을 비정상 종료(Abnormal termination)시킵니다.
  • '비정상 종료'란 프로그램에 어떤 예외적인 런타임 오류가 발생하여 더 이상 실행을 계속할 수 없는 상태를 뜻해요.
  • 예를 들어 숫자를 0으로 나누려고 시도하면 비정상 종료가 일어납니다.
  • 아주 중요한 점은, std::abort()어떠한 정리 작업도 하지 않는다는 것입니다.
#include <cstdlib> // std::abort()를 사용하기 위해 포함
#include <iostream>

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

    // 아래의 명령문들은 절대 실행되지 않습니다.
    std::cout << 2 << '\n';

    return 0;
}
  • std::terminate() 함수는 일반적으로 예외(Exceptions)와 함께 사용됩니다. (예외에 대해서는 이후 챕터에서 자세히 다룰게요.)
  • std::terminate를 명시적으로 호출할 수도 있지만, 대개는 발생한 예외가 제대로 처리(Handled)되지 않았을 때(그리고 기타 몇 가지 예외 관련 상황에서) 암시적으로 호출됩니다.
  • 기본적으로 std::terminate()std::abort()를 호출한답니다.

알고리즘(Algorithms)과 상태(State)

  • 알고리즘(Algorithm)은 어떤 문제를 해결하거나 유용한 결과를 만들어내기 위해 따라야 하는 '유한한 일련의 지시 사항'을 뜻해요.
  • 어떤 알고리즘이 함수 호출 간에 정보를 유지한다면, 그 알고리즘은 상태를 유지(Stateful)한다고 말합니다.
  • 반대로 무상태(Stateless) 알고리즘은 어떠한 정보도 저장하지 않으며 호출될 때마다 작업에 필요한 모든 정보를 전달받아야 해요.

의사 난수 생성기 (Pseudo-random number generators, PRNGs)

  • 무작위성을 모방하기 위해 프로그램은 일반적으로 '의사 난수 생성기'를 사용합니다.
  • 의사 난수 생성기(PRNG)란 난수 시퀀스처럼 보이는 특성을 가진 숫자들의 시퀀스를 생성하는 알고리즘이에요.
  • 기본적인 PRNG 알고리즘을 작성하는 것은 꽤 쉽습니다.
  • 다음은 16비트 의사 난수 100개를 생성하는 짧은 PRNG 예제입니다.
#include <iostream>

// 설명의 목적으로만 작성된 코드입니다. 실제로는 사용하지 마세요.
unsigned int LCG16() // 우리의 PRNG 함수
{
    static unsigned int s_state{ 0 }; // 이 함수가 처음 호출될 때 한 번만 초기화됩니다.

    // 다음 숫자를 생성합니다.

    // 누군가가 시퀀스의 다음 숫자가 무엇일지 쉽게 예측하지 못하도록
    // 큰 상수와 의도적인 오버플로우(overflow)를 사용하여 상태를 수정합니다.

    s_state = 8253729 * s_state + 2396403; // 먼저 상태를 수정합니다.
    return s_state % 32768; // 그런 다음 새로운 상태를 사용하여 시퀀스의 다음 숫자를 반환합니다.
}

int main()
{
    // 100개의 난수를 출력합니다.
    for (int count{ 1 }; count <= 100; ++count)
    {
        std::cout << LCG16() << '\t';

        // 숫자를 10개 출력했다면, 새로운 줄로 넘어갑니다.
        if (count % 10 == 0)
            std::cout << '\n';
    }

    return 0;
}

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

8397	18528	17747	9126	28505	13420	32479	23218	21477	30328	
20075	26558	20081	3716	13303	19146	24317	31888	12163	982	
1417	16540	16655	4834	16917	23208	26779	30702	5281	19124	
9767	13050	32045	4288	31155	17414	31673	11468	25407	11026	
4165	7896	25291	26654	15057	26340	30807	31530	31581	1264	
9187	25654	20969	30972	25967	9026	15989	17160	15611	14414	
16641	25364	10887	9050	22925	22816	11795	25702	2073	9516
  • 사실, 이 특정 알고리즘은 난수 생성기로서 성능이 아주 좋은 편은 아닙니다.
  • 결과가 짝수와 홀수를 번갈아 가며 나온다는 걸 눈치채셨나요? 이건 별로 무작위적이지 않죠.
  • 하지만 대부분의 PRNG는 이 LCG16()과 비슷하게 작동해요.
  • 단지 더 좋은 품질의 결과를 얻기 위해 더 많은 상태 변수와 더 복잡한 수학 연산을 사용할 뿐이랍니다.

의사 난수 생성기에 시드 설정하기 (Seeding a PRNG)

  • PRNG에 의해 생성된 "난수" 시퀀스는 사실 전혀 무작위가 아닙니다.
  • LCG16() 역시 결정론적(Deterministic)이거든요.
  • 특정한 초기 상태 값(예: 0)이 주어지면, PRNG는 매번 똑같은 숫자 시퀀스를 생성해 냅니다.
  • 위 프로그램을 세 번 실행해 보면 매번 똑같은 값의 시퀀스가 나오는 것을 확인할 수 있을 거예요.
  • 다른 출력 시퀀스를 생성하려면 PRNG의 초기 상태를 다르게 해주어야 합니다.
  • PRNG의 초기 상태를 설정하는 데 사용되는 값을 랜덤 시드(Random seed) 또는 줄여서 시드(Seed)라고 부릅니다.
  • 시드를 사용하여 PRNG의 초기 상태를 설정했을 때, 우리는 이것을 "시드가 설정되었다(seeded)"라고 표현해요.
#include <iostream>

unsigned int g_state{ 0 };

void seedPRNG(unsigned int seed)
{
    g_state = seed;
}

// 설명의 목적으로만 작성된 코드입니다. 실제로는 사용하지 마세요.
unsigned int LCG16() // 우리의 PRNG 함수
{
    // 누군가가 시퀀스의 다음 숫자가 무엇일지 쉽게 예측하지 못하도록
    // 큰 상수와 의도적인 오버플로우를 사용하여 상태를 수정합니다.

    g_state = 8253729 * g_state + 2396403; // 먼저 상태를 수정합니다.
    return g_state % 32768; // 그런 다음 새로운 상태를 사용하여 시퀀스의 다음 숫자를 반환합니다.
}

void print10()
{
    // 10개의 난수를 출력합니다.
    for (int count{ 1 }; count <= 10; ++count)
    {
        std::cout << LCG16() << '\t';
    }

    std::cout << '\n';
}

int main()
{
    unsigned int x {};
    std::cout << "Enter a seed value(시드 값을 입력하세요): ";
    std::cin >> x;

    seedPRNG(x); // 우리의 PRNG에 시드를 설정합니다.
    print10();   // 10개의 난수 값을 생성합니다.

    return 0;
}

다음은 이 프로그램을 3번 실행해 본 결과입니다.

Enter a seed value: 7
10458	3853	16032	17299	10726	32153	19116	7455	242	549	

Enter a seed value: 7
10458	3853	16032	17299	10726	32153	19116	7455	242	549	

Enter a seed value: 9876
24071	18138	27917	23712	8595	18406	23449	26796	31519	7922
  • 보시다시피 동일한 시드 값을 제공하면 똑같은 출력 시퀀스를 얻게 됩니다.
  • 다른 시드 값을 제공해야만 다른 출력 시퀀스를 얻을 수 있죠.

시드 품질과 시드 부족 (Seed quality and underseeding)

  • 프로그램을 실행할 때마다 다른 무작위 숫자를 생성하고 싶다면, 실행할 때마다 시드를 다르게 줄 방법이 필요합니다.
  • 안타깝게도 무작위 시드를 만들기 위해 PRNG를 사용할 수는 없어요.
  • 난수를 생성하려면 무작위 시드가 필요하니까 모순이 되죠.
  • 대신, 우리는 보통 시드 값을 생성하도록 설계된 특수한 시드 생성 알고리즘을 사용합니다.
  • PRNG가 생성할 수 있는 고유한 시퀀스의 이론적 최대 개수는 PRNG가 가진 상태의 비트 수에 의해 결정됩니다.
  • 예를 들어, 128비트 상태를 가진 PRNG는 이론적으로 최대 2^128개의 고유한 출력 시퀀스를 생성할 수 있습니다.
  • 하지만, 실제로 어떤 출력 시퀀스가 생성되는지는 초기 상태에 달려 있고, 이 초기 상태는 결국 시드에 의해 결정됩니다.
  • 따라서 현실적으로 PRNG가 실제로 생성할 수 있는 고유한 출력 시퀀스의 수는 프로그램이 제공할 수 있는 고유한 시드 값의 개수에 의해 제한됩니다.
  • PRNG에 충분한 비트의 양질의 시드 데이터가 제공되지 않는 경우, 우리는 이를 "시드가 부족하다(Underseeded)"라고 말합니다.
  • 시드가 부족한 PRNG는 품질이 어딘가 손상된 무작위 결과를 생성하기 시작할 수 있으며, 시드 부족 현상이 심할수록 결과의 품질은 더욱 나빠지게 됩니다.
  • 예를 들어, 시드가 부족한 PRNG는 다음과 같은 문제를 보일 수 있어요.
    1. 연속적으로 실행하여 생성된 난수 시퀀스들이 서로 높은 상관관계를 가질 수 있습니다.
    1. N번째 난수를 생성할 때, 특정 값은 아예 생성되지 않을 수 있습니다.
      예를 들어, 특정한 방식으로 시드가 부족한 메르센 트위스터는 첫 번째 출력으로 숫자 7이나 13을 절대 생성하지 못합니다.
    1. 누군가가 처음 생성된 난수 값(또는 처음 몇 개의 난수 값)을 보고 시드를 추측해 낼 수도 있습니다.
      그렇게 되면 앞으로 생성될 모든 난수를 알아낼 수 있게 되고, 시스템의 허점을 악용하거나 속임수를 쓸 수 있게 됩니다.

C++에서의 무작위화 (Randomization in C++)

  • C++에서 무작위화 기능은 표준 라이브러리의 <random> 헤더를 통해 접근할 수 있습니다.
  • 무작위 라이브러리 내에는 (C++20 기준으로) 사용할 수 있는 6가지 PRNG 제품군이 있습니다.
타입 이름 (Type name)제품군 (Family)주기 (Period)상태 크기 (State size)성능 (Performance)품질 (Quality)사용해야 할까요?
minstd_rand선형 합동 생성기 (Linear congruential generator)2³¹4 bytes나쁨 (Bad)끔찍함 (Awful)아니요 (No)
minstd_rand0선형 합동 생성기 (Linear congruential generator)2³¹4 bytes나쁨 (Bad)끔찍함 (Awful)아니요 (No)
mt19937메르센 트위스터 (Mersenne twister)2¹⁹⁹³⁷2500 bytes무난함 (Decent)무난함 (Decent)아마도요 (다음 섹션 참고)
mt19937_64메르센 트위스터 (Mersenne twister)2¹⁹⁹³⁷2500 bytes무난함 (Decent)무난함 (Decent)아마도요 (다음 섹션 참고)
ranlux24빼고 자리올림 (Subtract and carry)10¹⁷¹96 bytes끔찍함 (Awful)좋음 (Good)아니요 (No)
ranlux48빼고 자리올림 (Subtract and carry)10¹⁷¹96 bytes끔찍함 (Awful)좋음 (Good)아니요 (No)
knuth_b셔플된 선형 합동 생성기 (Shuffled linear congruential generator)2³¹1028 bytes끔찍함 (Awful)나쁨 (Bad)아니요 (No)
default_random_engine위 중 하나 (구현에 따라 다름)다양함다양함????아니요 (No)
rand()선형 합동 생성기 (Linear congruential generator)2³¹4 bytes나쁨 (Bad)끔찍함 (Awful)아니요 (No)
  • knuth_b default_random_engine 또는 rand() (C 언어와의 호환성을 위해 제공되는 난수 생성기)를 사용할 이유는 전혀 없습니다.
  • C++20을 기준으로 할 때, 메르센 트위스터(Mersenne Twister) 알고리즘은 C++에서 기본으로 제공하는 PRNG 중 성능과 품질 모두 무난한 유일한 생성기입니다.

그럼 메르센 트위스터(Mersenne Twister)를 사용해야겠죠?

  • 아마도 그럴 겁니다. 대부분의 애플리케이션에서 메르센 트위스터는 성능과 품질 측면에서 충분히 훌륭합니다.
  • 하지만 현대의 PRNG 기준으로 볼 때, 메르센 트위스터는 약간 구식이라는 점을 기억해 두시면 좋습니다.
  • 메르센 트위스터의 가장 큰 문제점은 624개의 생성된 숫자를 보고 나면 다음 결과를 예측할 수 있다는 점입니다.
  • 따라서 예측 불가능성이 요구되는 애플리케이션에는 절대 적합하지 않습니다.
  • 최고 품질의 난수 결과가 필요한 애플리케이션(예: 통계 시뮬레이션), 가장 빠른 속도가 필요한 경우, 또는 예측 불가능성이 중요한 애플리케이션(예: 암호화)을 개발 중이라면 서드파티(3rd party) 외부 라이브러리를 사용해야 합니다.
  • 현재 시점에서 인기 있는 선택지들은 다음과 같습니다:
    1. 암호화 목적이 아닌(예측 가능한) 일반 PRNG의 경우: Xoshiro 제품군과 Wyrand
    1. 암호화 목적의(예측 불가능한) PRNG의 경우: Chacha 제품군

메르센 트위스터를 사용하여 C++에서 난수 생성하기

  • 메르센 트위스터(Mersenne Twister) PRNG는 이름이 멋질 뿐만 아니라, 아마도 모든 프로그래밍 언어를 통틀어 가장 인기 있는 PRNG일 것입니다. 오늘날의 기준으로는 조금 오래된 방식이긴 하지만, 일반적으로 훌륭한 결과물과 준수한 성능을 보여줍니다.
  • C++의 random 라이브러리는 두 가지 메르센 트위스터 타입을 지원합니다.
  • mt19937 32비트 부호 없는 정수를 생성하는 메르센 트위스터입니다.
  • mt19937_64 64비트 부호 없는 정수를 생성하는 메르센 트위스터입니다.
#include <iostream>
#include <random> // std::mt19937을 사용하기 위해 포함합니다

int main()
{
	std::mt19937 mt{}; // 32비트 메르센 트위스터 객체를 생성합니다

	// 난수를 여러 개 출력해 봅니다
	for (int count{ 1 }; count <= 40; ++count)
	{
		std::cout << mt() << '\t'; // 난수를 하나 생성합니다

		// 5개의 숫자를 출력했다면, 새로운 줄로 넘어갑니다
		if (count % 5 == 0)
			std::cout << '\n';
	}

	return 0;
}
  • 가장 먼저 모든 난수 기능을 담고 있는 <random> 헤더를 포함했습니다.
  • 그다음 std::mt19937 mt라는 구문을 통해 32비트 메르센 트위스터 엔진을 생성했죠.
  • 그런 뒤 무작위 32비트 부호 없는 정수를 생성하고 싶을 때마다 mt()를 호출했습니다.

메르센 트위스터로 주사위 굴리기

  • 32비트 PRNG0부터 4,294,967,295 사이의 난수를 생성하지만, 우리가 항상 이처럼 큰 범위의 숫자를 원하는 것은 아닙니다.
  • 만약 보드게임이나 주사위 게임을 시뮬레이션하는 프로그램이라면, 1부터 6 사이의 난수를 생성하여 6면체 주사위 굴리기를 흉내 내고 싶을 것입니다.
  • 만약 던전 탐험 게임이고 플레이어가 몬스터에게 7에서 11 사이의 피해를 주는 검을 가지고 있다면, 플레이어가 몬스터를 때릴 때마다 7에서 11 사이의 난수를 생성해야겠죠.
  • 안타깝게도 PRNG 자체는 이런 기능을 할 수 없습니다. 오직 전체 범위의 숫자만 생성할 수 있죠.
  • 우리가 필요한 것은 PRNG에서 출력된 숫자를 우리가 원하는 더 작은 범위의 값으로 변환해 주는 방법입니다.
  • 물론 각 값이 나올 확률은 동일해야 합니다. 우리가 직접 이 작업을 수행하는 함수를 작성할 수도 있지만, 편향되지 않은 결과를 만들어내는 것은 생각보다 까다로운 작업입니다.
  • 다행히도 random 라이브러리에는 이를 도와주는 난수 분포(Random number distributions) 기능이 있습니다.
  • 난수 분포는 PRNG의 출력을 다른 숫자 분포로 변환해 줍니다.
  • random 라이브러리에는 여러 가지 난수 분포가 있지만, 통계 분석을 하지 않는 이상 대부분은 사용할 일이 없을 겁니다.
  • 하지만 정말 유용하게 쓰이는 분포가 딱 하나 있습니다. 바로 균등 분포(Uniform distribution)입니다.
  • 이는 두 숫자 XY 사이에서 동일한 확률로 결과를 생성하는 난수 분포입니다.
#include <iostream>
#include <random> // std::mt19937 및 std::uniform_int_distribution을 위해 포함합니다

int main()
{
	std::mt19937 mt{};

	// 1부터 6 사이의 숫자를 균등하게 생성하는 재사용 가능한 난수 생성기를 만듭니다
	std::uniform_int_distribution die6{ 1, 6 }; // C++14의 경우, std::uniform_int_distribution<> die6{ 1, 6 }; 를 사용하세요

	// 난수를 여러 개 출력해 봅니다
	for (int count{ 1 }; count <= 40; ++count)
	{
		std::cout << die6(mt) << '\t'; // 여기서 주사위를 굴립니다

		// 10개의 숫자를 출력했다면, 새로운 줄로 넘어갑니다
		if (count % 10 == 0)
			std::cout << '\n';
	}

	return 0;
}

이 코드는 다음과 같은 결과를 생성합니다:

3       1       3       6       5       2       6       6       1       2
2       6       1       1       6       1       4       5       2       5
6       2       6       2       1       3       5       4       5       6
1       4       2       3       1       2       2       6       2       1
  • 이전 예제와 비교했을 때 눈에 띄는 차이점은 딱 두 가지입니다.
  • 첫째, 16 사이의 숫자를 생성하기 위해 균등 분포 변수 die6 를 만들었습니다.
  • 둘째, 32비트 부호 없는 정수를 생성하기 위해 mt()를 호출하는 대신, 이제는 16 사이의 값을 생성하기 위해 die6(mt)를 호출하고 있습니다.

위 프로그램은 생각만큼 무작위가 아닙니다!

  • 프로그램을 여러 번 실행해 보면 매번 완전히 똑같은 숫자들이 출력된다는 것을 알 수 있습니다.
  • 시퀀스(Sequence) 내의 각 숫자는 이전 숫자에 대해 무작위일지 몰라도, 전체 시퀀스 자체는 전혀 무작위가 아니었던 거죠.
  • 프로그램을 실행할 때마다 정확히 동일한 결과가 나옵니다.
  • 우리의 코드에서는 메르센 트위스터를 단순히 값 초기화하고 있기 때문에, 프로그램이 실행될 때마다 동일한 기본 시드 값으로 초기화되고 있었던 것입니다. 시드가 같으니, 생성되는 난수도 같을 수밖에 없죠.
  • 프로그램을 실행할 때마다 전체 시퀀스가 다르게 무작위화되려면, 고정된 숫자가 아닌 시드를 골라야 합니다.
  • 가장 먼저 떠오르는 생각은 "시드로 쓸 난수가 필요하겠네!"일 텐데요. 좋은 생각이지만, 난수를 생성하기 위해 난수가 필요하다면 모순에 빠지게 됩니다.
  • 사실 시드가 굳이 난수일 필요는 없습니다.
  • 프로그램이 실행될 때마다 변하는 어떤 값만 선택하면 됩니다.
  • 그러면 PRNG를 사용하여 그 시드로부터 고유한 유사 난수 시퀀스를 생성할 수 있습니다.
  • 이를 위해 일반적으로 두 가지 방법이 사용됩니다.
    1. 시스템 클록(System clock) 사용하기
    1. 시스템의 랜덤 디바이스(Random device) 사용하기

시스템 클록으로 시드 설정하기

  • 프로그램을 실행할 때마다 항상 달라지는 것이 무엇일까요?
  • 프로그램을 아주 정확히 똑같은 시간에 두 번 실행하지 않는 이상, 정답은 바로 '현재 시간'입니다.
  • 따라서 현재 시간을 시드 값으로 사용하면 프로그램이 실행될 때마다 다른 난수 세트를 생성하게 됩니다.
  • C와 C++에서는 오랫동안 현재 시간 std::time() 함수를 사용하여 PRNG의 시드를 설정해 왔으므로, 기존 코드에서 이런 방식을 많이 보실 수 있을 겁니다.
  • 다행히도 C++에는 시드 값을 생성하는 데 사용할 수 있는 다양한 시계를 포함한 <chrono> 라이브러리가 있습니다.
  • 프로그램을 연속으로 빠르게 실행했을 때 두 시간 값이 동일해질 가능성을 최소화하려면, 최대한 빠르게 변하는 시간 단위를 사용해야 합니다.
  • 이를 위해, 시계가 측정할 수 있는 가장 이른 시간부터 지금까지 시간이 얼마나 지났는지 시계에게 물어볼 것입니다.
    이 시간은 "틱(Ticks)"이라는 매우 작은 단위로 측정됩니다 (보통 나노초 단위지만, 밀리초일 수도 있습니다).
#include <iostream>
#include <random> // std::mt19937을 위해 포함
#include <chrono> // std::chrono를 위해 포함

int main()
{
	// steady_clock을 사용하여 메르센 트위스터의 시드를 설정합니다
	std::mt19937 mt{ static_cast<std::mt19937::result_type>(
		std::chrono::steady_clock::now().time_since_epoch().count()
		) };

	// 1부터 6 사이의 숫자를 균등하게 생성하는 재사용 가능한 난수 생성기
	std::uniform_int_distribution die6{ 1, 6 };

	// 난수 여러 개 출력
	for (int count{ 1 }; count <= 40; ++count)
	{
		std::cout << die6(mt) << '\t'; // 여기서 주사위를 굴립니다

		// 10개를 출력하면 줄바꿈
		if (count % 10 == 0)
			std::cout << '\n';
	}

	return 0;
}
  • 이전 프로그램에서 바뀐 부분은 두 가지뿐입니다.
  • 첫째, 시계에 접근할 수 있도록 <chrono>를 포함했습니다.
  • 둘째, 메르센 트위스터의 시드 값으로 시계의 현재 시간을 사용했습니다.
  • 이제 프로그램을 여러 번 실행해 보면 매번 결과가 달라지는 것을 직접 확인하실 수 있을 거예요.
  • 이 방식의 단점은 프로그램을 짧은 시간 안에 여러 번 연속으로 실행하면, 각 실행을 위해 생성된 시드가 크게 다르지 않을 수 있다는 점입니다.
  • 이는 통계적 관점에서 난수 결과의 품질에 영향을 미칠 수 있습니다.
  • 일반적인 프로그램에서는 문제가 되지 않지만, 독립적이고 높은 품질의 결과가 필요한 프로그램에서는 이 시드 설정 방식이 불충분할 수 있습니다.
profile
안녕하세요.

0개의 댓글