개념적으로 constexpr은 값이 상수임을 알릴 뿐만 아니라 컴파일 타임에 알려진다는 점을 나타낸다.

 

객체에 constexpr이 적용되면 상수화되고 해당 값은 컴파일 타임에 알려진다. 하지만 const는 그럴거라는 보장이 없다.

모든 constexpr 객체는 const이지만 모든 const 객체는 constexpr인것은 아니다.

 

다만 이 개념이 함수에 적용되면 약간 다르게 적용된다.

constexpr 함수는 컴파일 타임의 상수를 인수로 호출한 경우에는 컴파일 타임에 상수를 산출하지만 런타임에 산출된 값으로 호출하면 런타임에 값을 산출한다.

 

constexpr 함수에 대한 올바른 관점

◾ 컴파일 타임 상수를 요구하는 문맥에 constexpr 함수를 사용할 수 있다. 인수의 값이 컴파일 타임에 알려진다면 결과는 컴파일 타임에 계산되고 컴파일 타임에 알려지지 않는다면 코드의 컴파일이 거부된다.

◾ 컴파일 타임에 알려지지 않는 하나 이상의 값들로 constexpr 함수를 호출하면 보통의 함수처럼 작동한다. 별도의 함수로 나눌 필요 없이 하나의 constexpr  함수를 두 가지 용도로 사용하면 된다.

 

constexpr int pow(int base, int exp) noexcept {
    ...
}

constexpr auto cnumConds = 5;
std::array<int, pow(3, numConds)> results;

함수에 constexpr이 붙었다고 해서 결과값이 반드시 const인 것은 아니다.

 

constexpr int pow(int base, int exp) noexcept { // C++11
    return (exp == 0 ? 1 : base * pow(base, exp - 1));
}

C++11에서 constexpr 함수는 실행 가능 문장이 많아야 하나만 있어야 한다. 보통은 return문이 될 것이고 삼항연산자와 재귀를 이용해서 함수의 표현력을 확장시킬 수 있다.

 

constexpr int pow(int base, int exp) noexcept { // C++14
    auto reuslt = 1;
    for (int i = 0; i < exp; ++i) result *= base;
    
    return result;
}

C++14는 제약이 상당히 느슨해져서 실행 가능 문장이 여러줄이어도 괜찮다. 다만 반드시 리터럴 타입들을 받고 돌려주어야 한다.

 

 

class Point {
public:
    constexpr Point(double xVal = 0, double yVal = 0) noexcept : x(xVal), y(yVal) {}
    constexpr double xValue() const noexcept { return x; }
    constexpr double yValue() const noexcept { return y; }
};
...
constexpr Point p1(9.4, 27.7); // OK
constexpr Point p2(28.8, 5.3); // OK

생성자를 constexpr로 선언할 수 있는 이유는 주어진 인수들이 컴파일 타임에 알려진다면 생성된 객체의 멤버 변수들 역시 컴파일 타임에 알려질 수 있기 때문이다.

컴파일 타임에 알려진 값으로 초기화된 객체는 getter역시 constexpr이 될 수 있다. 연쇄적으로 다 가능해진다.

 

class Point {
public:
    ...
    constexpr void setX(double newX) noexcept { x = newX; } // C++14
    constexpr void setY(double newY) noexcept { y = newY; }
};

만약 setter를 constexpr로 구현한다면 C++11에서는 구현할 수 없다. constexpr 멤버 함수는 암묵적으로 const로 선언되고 void타입은 리터럴이 아니기 때문이다.

 

연산들을 컴파일 타임으로 옮길수록 실행시간은 빨라지지만 컴파일 시간이 길어지게 된다.

 

constexpr역시 가능하면 항상 사용하는것이 좋다.

덧붙여서 constexpr 함수에서는 입출력 문장들이 허용되지 않는다.

 

◾ constexpr 객체는 const이며, 컴파일 도중에 알려지는 값들로 초기화된다.

◾ constexpr 함수는 그 값이 컴파일 도중에 알려지는 인수들로 호출하는 경우에는 컴파일 타임 결과를 산출한다.

◾ constexpr 객체나 함수는 비constexpr 객체나 함수보다 광범위한 문맥에서 사용할 수 있다.

◾ constexpr은 객체나 함수의 인터페이스의 일부이다.

함수의 예외 방출 행동에 관해서 의미있는 정보는 함수가 예외를 하나라도 던지는지, 아니면 절대로 던지지 않는지에 대한 이분법적 정보뿐이다.

 

함수의 예외 방출 행동은 매우 중요한 사항이다. 함수의 호출자는 호출하려는 함수의 noexcept 여부를 조회할 수 있고 그 조회 결과에 따라 호출 코드의 예외 안정성이나 효율성에 영향을 미친다. const 한정자 만큼이나 중요하다.

 

int f(int x) throw(); // C++98 스타일
int f(int x) noexcept; // C++11 스타일

예외를 만들지 않는 함수에 noexcept를 적용하는 것은 인터페이스 설계상의 문제뿐만 아니라 실제로 컴파일러가 더 나은 목적 코드를 만들어낼 수 있다.

만약 예외를 던지지 않겠다고 명시했음에도 예외가 발생했을 때, throw는 호출 스택이 해당 함수를 호출한 지점에 도달할 때까지 풀리고 noexcept는 호출 스택이 풀릴 수도, 아닐 수도 있기 때문에 컴파일러가 조금 더 최적화를 할 수 있게 된다.

 

 

noexcept가 큰 의미를 갖는 다른 예를 한번 들어보자.

벡터에 push_back을 통해 새 요소를 집어넣을 때 용량을 초과하게 된다면 새로운 메모리를 할당받아서 크기를 확장시키고 기존에 있던 요소들을 복사가 아닌 이동시키는 것이 최적화 측면에서 더 낫다. 그런데 만약 이동 도중에 예외가 발생한다면?

이미 이동이 진행중이라 기존의 벡터가 수정된 상태이고 다시 원래대로 되돌린다고 해도 또 한번 예외가 발생할 수도 있다.

그래서 이동 연산이 예외를 방출하지 않음이 확실하지 않은 경우는 이동 대신 복사로 대체되어 수행될 수 있다.

 

실제로 벡터의 push_back등 표준 라이브러리의 함수들은 가능하면 이동하되 필요하면 복사를 수행한다.

이동 연산이 예외를 방출하지 않는다는 것이 확실한 경우에만 이동 연산을 수행한다.

함수가 이를 알아내는 방법이 바로 noexcept이다. 주어진 연산이 noexcept로 선언되어 있는지 여부를 점검하게 된다.

 

참고로 벡터의 push_back에서 이동 연산과 복사 연산을 선택할 때 사용되는 함수는 move_if_noexcept이다. 예외 방출 여부에 따라 왼값 또는 오른값을 골라서 반환시켜준다.

 

swap역시 noexcept가 바람직하게 적용되는 경우이다.

 

 

최적화와 관련된 좋은점에 대해서만 언급했지만 최적화보다는 정확성이 더 중요하다. noexcept는 함수의 인터페이스 일부이기 때문에 확실한 경우에만 noexcept로 선언해야 한다. 만약 나중에 제거하게 되는경우 클라이언트 코드가 깨질 가능성이 존재한다.

 

대부분의 함수는 예외에 중립적(다른 함수의 예외 통과)이라서 noexcept가 될 수 없다.

함수를 noexcept로 만들기 위해 구조를 작위적으로 비틀기보다는 자연스러운 noexcept 구현이 되는 것이 중요하다.

 

◾ noexcept는 함수의 인터페이스의 일부이다. 이는 호출자가 noexcept 여부에 의존할 수 있음을 뜻한다.

◾ noexcept 함수는 비noexcept 함수보다 최적화의 여지가 크다.

◾ noexcept는 이동 연산들과 swap, 메모리 해제 함수들, 그리고 소멸자들에 특히나 유용하다.

◾ 대부분의 함수는 noexcept가 아니라 예외에 중립적이다.

Effective C++에서 다루었던 내용이 반복자에서도 마찬가지로 적용된다.

반복자가 가리키는 것을 수정할 필요가 없다면 const_iterator를 사용해주면 된다.

C++98때와는 다르게 이제는 컨테이너에서 cbegin, cend로 const_iterator를 매우 쉽게 얻어낼 수 있다.

 

const_iterator를 사용하지 못하는 경우는 한 가지, 최대한 일반적인 라이브러리를 작성할 때이다.

begin, end함수를 비멤버 함수로 제공해야 할 때, C++11에서는 begin, end 딱 두가지만 추가되었기때문에 비멤버 const_iterator를 작성하더라도 C++11 환경에서는 동작하지 않는다.

물론 이런 경우라면 const 참조 매개변수를 통해 비멤버 begin, end를 호출하면 된다.

 

template<class C>
auto cbegin(const C& container)->decltype(std::begin(container)) {
    return std::begin(container); // 멤버 함수 begin 또는 cbegin 호출
}

C++11같이 비멤버 const_iterator가 지원되지 않는다면 그냥 begin을 반환할 것이고 지원된다면 cbegin을 반환할 것이다.

이 템플릿은 내장 배열 타입일때도 사용이 가능해진다. 반복자 begin 대신 배열의 첫 원소를 가리키는 포인터가 반환된다.

 

설명이 조금 길어졌지만 요점은 반복자 역시 가능하면 const_iterator를 사용하라는 것이다.

 

◾ iterator보다 const_iterator를 선호하라.

◾ 최대한 일반적인 코드에서는 begin, end, rbegin 등의 비멤버 버전들을 해당 멤버 함수들보다 선호하라.

파생 클래스의 가상 함수 구현은 대체로 기반 클래스의 가상 함수 부분을 재정의 하게 된다.

 

파생 클래스에서 함수의 재정의가 일어나려면 다음과 같은 필수 조건들을 만족해야 한다.

 

◾ 기반 클래스의 함수가 반드시 가상 함수이어야 한다.

◾ 기반 함수의 파생 함수의 이름이 반드시 동일해야 한다.(소멸자 제외)

◾ 기반 함수의 파생 함수의 매개변수 타입들이 반드시 동일해야 한다.

◾ 기반 함수와 파생 함수의 상수성(const)이 반드시 동일해야 한다.

◾ 기반 함수와 파생 함수의 반환 타입과 예외 명세가 반드시 호환되어야 한다.

 

위의 제약들은 C++98에도 있던것들이고 C++11에서 참조 한정사들이 동일해야 한다는 제약이 하나 더 추가됐다.

참조 한정사는 멤버 함수를 왼값 또는 오른값에만 사용할 수 있도록 제약시키는 것이다.

 

class Widget {
public:
    void doWork() &; // *this가 왼값일때만 호출
    void doWork() &&; // *this가 오른값일때만 호출
    ...
};

기반 함수가 상수성을 가지고 있다면 파생 함수에서도 상수성을 가져야하는 것처럼 참조 한정사 역시 동일하게 유지시켜주어야 한다. 만약 참조 한정사가 서로 다르다면 재정의가 일어나지 않는다.

 

class Base {
public:
    virtual void mf1() const;
    virtual void mf2(int x);
    virtual void mf3() &;
    void mf4() const;
};

class Derived: public Base {
public:
    virtual void mf1(); // 상수성 유지 x
    virtual void mf2(unsigned int x); // 매개변수 타입이 다름
    virtual oid mf3() &&; // 참조 한정사가 다름
    void mf4() const; // 가상 함수가 아님
};

위의 4가지 경우 모두 재정의가 이루어지지 않은 경우들이다. 무서운점은 컴파일러가 경고를 띄워주지 않는다는 점이다. 명백한 실수를 저질렀음에도 모르고 넘어갈 수 있는 것이다.

 

그래서 파생 클래스에서 함수를 재정의 할 때, 기반 클래스의 함수를 재정의 한다고 명시하기 위해 override 식별자를 사용한다.

 

class Derived: public Base {
public:
    virtual void mf1() override;
    virtual void mf2(unsigned int x);
    virtual void mf3() && override;
    virtual void mf4() override;
};

이제 이 코드는 컴파일되지 않는다. 재정의 명시를 했지만 재정의가 이루어진게 하나도 없기 때문이다.

 

마지막으로 참조 한정사가 붙은 함수가 오버로딩 되었다면 오버로딩된 모든 함수들에도 참조 한정사를 지정해주어야 한다. 그렇지 않으면 중의적인 호출이 되기 때문에 의도하지 않은 호출이 이뤄질 수 있다.

 

◾ 재정의 함수는 override로 선언하라.

◾ 멤버 함수 참조 한정사를 이용하면 멤버 함수가 호출되는 객체(*this)의 왼값 버전과 오른값 버전을 다른 방식으로 처리할 수 있다.

116. 평균값 정리

도박사의 오류(평균의 오류)라는 것이 있다.

동전을 연속으로 6번 던졌을 때, 5번 연속 앞면이 나온다면 다음 시행시 확률상 뒷면이 나올거라 기대하는 것이다.

하지만 완전한 독립시행이라서 언제나 50%이다.

 

큰 수의 법칙을 떠올리며 시행횟수가 많아질수록 점점 평균에 근접할거라고 착각하는 것이다. 하지만 이는 다르게 생각하면 균형을 맞추기 위해 뒷면이 나올 확률이 증가해야 한다는 결론에 도달하게 된다.

완전한 독립시행에서 종속시행이라는 결론을 도출하는 것인데 이는 말도 안되는 오류이다.

 

이성적으로 보면 말도 안되지만 그런 확률을 겪은 플레이어가 오류에 빠지지 않게 할 수는 없다. 왜냐면 확률이 안정적인 평균으로 수렴할만큼의 수많은 시행횟수를 시도하는 플레이어는 거의 없다고 봐도 무방하기 때문이다. 게다가 거기까지 도달하기 위해 겪는 부정적인 경험을 무시할 수 없다.

 

그래서 큰 수의 법칙에 의해 확률이 수렴하기 전의 확률 변동을 받아들이게 하기 위해서는 완전한 독립시행이 아닌 종속시행으로 어느정도 보정하는 경우도 있다. (ex:강화실패 시 확률 상승)

확률보정을 유저가 느끼지 못하게 뒤에서 조절할수도 있고 강화시도처럼 직접 확인할수 있게 제공할수도 있으므로 상황에 맞게 선택하여 사용하면 된다.

 

 

117. 베이즈의 정리

확률 이론에서 가장 중요한 식이다.

 

여기에서 유도된 식이다. P(B)로 나누면 베이즈의 정리가 된다.

베이즈의 정리는 조건부 확률을 구하는 한 가지 방법이다.

 

정말 유명한 문제로 베이즈의 정리를 증명해보자.

 

몬티홀 문제

아무런 선택을 하지 않았을 때 상품을 얻을 확률은 P(1)=P(2)=P(3)=1/3 으로 동일하다.

하지만 문을 고르고 사회자가 꽝인 문을 연 뒤에 선택을 바꿨을 때, 확률이 변동하게 된다.

 

참가자가 1번 문을 고른 상태에서 사회자가 2번 문을 열 확률을 b라고 해보자.

 

참가자는 언제나 1번문을 열고, 사회자는 절대 상품이 들어있는 문을 열 수 없으므로 확률은 위와 같다.

 

P(A|b) : b문을 열었을 때, A문에 경품이 있을 확률

P(A) : A문에 경품이 있을 확률

P(b|A) : A문에 경품이 있을 때, 진행자가 B문을 선택할 확률

 

식에 대입하며 계산하면 1/3이 나온다. 진행자가 문을 열든 말든 내가 처음 골랐을 때의 확률은 그대로이다.

하지만 선택을 바꾸면 남은 확률이 합산되기 때문에 2/3으로 변동한다. 사회자가 정보를 알고 정답문을 절대 열지 않는다는것이 핵심이다.

 

 

118. 누적분포함수

확률질량함수, PMF

 

누적분포함수, CDF

 

확률밀도함수, PDF

 

 

119. 엘로(ELO) 평점 시스템

두 유저간 등급 점수의 차이가 있으면 누가 이겼는지에 따라 점수의 증감량이 다르다.

전통적인 ELO 시스템은 로지스틱 함수를 사용하여 계산한다.

 

로지스틱 함수의 기본형

L : 곡선의 최댓값

k : 증가율

x0 : 그래프의 중앙점

 

플레이어 A가 이길 확률

Ra, Rb : A, B 플레이어의 현재 평점

10, 400과 같은 상수는 일종의 매직 넘버이다. 상대방보다 평점이 400점 높다면 승률이 10배 높다는 의미이다.

800점 높으면 100배 높아진다.

400은 임의의 수이기 때문에 게임의 상황에 맞게 변경해도 된다.

 

Sa : 승/무/패별로 얻는 점수 (0~1)

Ea : 플레이어가 이길 확률

k : 최대 점수 상수

 

승률을 구했으면 경기 결과에 따라 가중치를 두어 점수의 가감이 이루어진다.

이길 확률이 낮은 플레이어가 이기게 되면 Sa-Ea는 1에 가깝기 때문에 많은 점수를 얻고 반대로 이길 확률이 높은 플레이어가 지게되면 -1에 가깝기 때문에 많은 점수를 잃는다.

보통 k는 32로 두는편이다. 물론 임의의 수로 설정해도 무관하다. 증감되는 수의 최대치일 뿐이다.

 

평점 산정 시스템이 필요한 경우 ELO 평점 시스템으로 시작하는게 단순해서 이해와 구현이 쉽다.

하지만 리터럴 상수에 의존하기 때문에 상수값을 잘 설정해야 한다는 단점이 있다.

또한 기본적으로 1:1 상황에서만 동작이 가능하므로 ELO 평점 시스템은 맞지 않고 플레이어들의 실력이 같은 분포 곡선을 그리고 있어야한다는 가정이 있어야하지만 실제로는 편차가 존재하기 때문에 정규화 등의 과정을 거쳐서 수정하여 적용해야 한다.

'이론 > 게임수학' 카테고리의 다른 글

[게임수학] 확률과 통계 (3)  (0) 2023.01.16
[게임수학] 확률과 통계 (2)  (0) 2023.01.16
[게임수학] 확률과 통계 (1)  (0) 2023.01.14
[게임수학] 회전과 보간 (4)  (0) 2023.01.13
[게임수학] 회전과 보간 (3)  (0) 2023.01.13

+ Recent posts