std::chrono - 1. Duration

2022. 9. 22. 16:54C++

이전 글에서 time_t 타입의 문제는 단위가 달라지면 값이 가지는 의미가 달라지는데도 정상적으로 컴파일이 된다는 것 이었다. 그럼 단위가 다르면 컴파일 에러를 띄우는 방법이 있을까?

 

바로, 각 단위에 대한 타입을 만들어주는 것이다.

class seconds
{
    ...
}

class minutes
{
    ...
}

void f(seconds sec);

int main()
{
    minutes mins{10};
    f(mins); // Compile Error!!
    f(10); // Compile Error!!
}

위 예제를 보면 초(seconds)와 분(minutes)을 나타내는 각각의 클래스를 만들어주었다. 이제 main 함수를 보면 두 번의 f 함수의 호출은 실패하는데, 첫 번째는 매개변수와 인자의 타입이 다르기 때문인 것을 쉽게 이해할 수 있다. 그럼 두 번째는 왜 실패해야 할까? 함수 f의 매개변수 타입을 minutes 타입으로 변경했다고 해보자. 그럼 f(10)은 10초가 아닌 10분을 의미하게 되므로 문제가 될 것이다. 따라서 숫자 리터럴의 묵시적 변환(implicit conversion)은 막아야 한다.

 

각 단위에 대한 타입을 만들어서 단위가 달라졌을때 최소한 정상적으로 컴파일이 되는 문제는 막을 수 있었다. 하지만 첫 번째 f 함수의 호출에서 컴파일 에러를 띄우는 것은 유연성이 좀 떨어진다는 생각이 든다. 컴파일 타임에, 정 안되면 런타임에라도 다음과 같이 분을 초로 묵시적 변환을 통해 인자로 넘겨줄 수 있으면 좋을 것 같다.

void f(seconds sec);

int main()
{
    minutes mins{10};
    f(seconds{mins}); // OK!!
}

 

chrono::duration 클래스는 위의 언급된 점들을 모두 반영하여 설계된 템플릿 클래스이다.

template<class Rep, class Period = std::ratio<1>> 
class duration;

위 링크로 들어가서 이 클래스의 설명을 보면 이 클래스는 tick의 개수와 tick period으로 이루어져 있다고 나와있는데, tick period는 하나의 tick이 넘어가기 위해 얼마의 시간이 지나야 하는지에 대한 초 단위 값을 나타낸다. 예를 들어, tick period가 0.001(s)이고 5 tick이라면 0.005(s)가 되는 것이다.

 

이제 템플릿 파라미터를 살펴보자.

 

Rep 템플릿 파라미터는 tick의 수를 나타내기 위한 타입이다.

Period 템플릿 파라미터는 tick 간 간격을 나타내며, std::ratio [각주:1] 클래스를 받는다.

 

예를 들어, 다음과 같이 프레임을 나타내는 타입 별칭(type alias)을 정의할 수 있다.

using frames = duration<float, std::ratio<1, 60>>

 

chrono 헤더 파일에는 자주 사용되는 단위들이 미리 정의되어 있다.

// <chrono>
using nanoseconds  = duration<long long, nano>;
using microseconds = duration<long long, micro>;
using milliseconds = duration<long long, milli>;
using seconds      = duration<long long>;
using minutes      = duration<int, ratio<60>>;
using hours        = duration<int, ratio<3600>>;
#if _HAS_CXX20
using days   = duration<int, ratio_multiply<ratio<24>, hours::period>>;
using weeks  = duration<int, ratio_multiply<ratio<7>, days::period>>;
using years  = duration<int, ratio_multiply<ratio<146097, 400>, days::period>>;
using months = duration<int, ratio_divide<years::period, ratio<12>>>;
#endif // _HAS_CXX20

또한 각 단위를 타이핑 할 필요 없도록 다음 6가지의 사용자 정의 리터럴을 사용할 수도 있다.

using namespace std::chrono_literals;

auto nanoseconds{10ns};
auto microseconds{10us};
auto milliseconds{10ms};
auto seconds{10s};
auto minutes{10min};
auto hours{10h};

 

이제 chrono 라이브러리를 사용하여 처음 예제에 적용해보자.

#include <chrono>

using namespace std::chrono;

void f(seconds sec);

int main()
{
    minutes mins{10};
    f(mins); // OK!!
    f(10); // Compile Error!!
}

우리가 바랬던 대로 분은 초로 묵시적 변환되었으며, 숫자 리터럴의 묵시적 변환은 컴파일 에러가 발생한다.

이 예제를 살짝 바꿔보도록 하자.

#include <chrono>

using namespace std::chrono;

void f(minutes mins);

int main()
{
    seconds sec{10};
    f(sec); // Compile Error!!
}

이번에는 매개변수로 분을 받고, 인자로 초를 넘겼는데 컴파일에 실패했다. 이유는 데이터에 손실이 발생하는 변환(lossy conversion)이기 때문이다. 분을 초로 변환할 때는 60을 곱하면 되기 때문에 모든 분은 초로 표현이 가능했다. 하지만 초를 분으로 변환하면 60을 나눠야 하기에 손실이 발생한다.

 

이렇게 손실이 있음에도 변환을 하고싶은 경우 사용하는 것이 chrono::duration_cast이다.

void f(minutes mins);

seconds sec{10};
f(duration_cast<minutes>(sec)); // OK!!

 

MSVC의 duration_cast 구현을 살펴보자.

using _CF = ratio_divide<_Period, typename _To::period>; #1

using _ToRep = typename _To::rep;
using _CR    = common_type_t<_ToRep, _Rep, intmax_t>; #2

constexpr bool _Num_is_one = _CF::num == 1;
constexpr bool _Den_is_one = _CF::den == 1;

if (_Den_is_one) {
    if (_Num_is_one) {
        return static_cast<_To>(static_cast<_ToRep>(_Dur.count()));
    } else {
        return static_cast<_To>(
            static_cast<_ToRep>(static_cast<_CR>(_Dur.count()) * static_cast<_CR>(_CF::num)));
    }
} else {
    if (_Num_is_one) {
        return static_cast<_To>(
            static_cast<_ToRep>(static_cast<_CR>(_Dur.count()) / static_cast<_CR>(_CF::den)));
    } else {
        return static_cast<_To>(static_cast<_ToRep>(
            static_cast<_CR>(_Dur.count()) * static_cast<_CR>(_CF::num) / static_cast<_CR>(_CF::den))); #3
    }
}

복잡해 보이지만, 전혀 그렇지 않다. 코드에 #으로 표시된 부분만 보면 끝난다.

나머지 분기와 _Num_is_one, _Den_is_one_ 같은 것들은 곱셈 및 나눗셈을 줄이기 위한 최적화 코드이다.

#1 : 변환 전, 후 durationratio를 나눠서 최종 변환을 위해 곱해야 할 ratio를 구한다.

#2 : 변환 과정에서 데이터 손실을 막기 위해 변환 전, 후 타입의 공통된 타입을 구한다.

#3 : #1, #2에서 구한 정보들로 변환을 수행한다.

 

정수 타입 duration 간 변환 전, 후의 ratio를 나눴을 때 분모가 1인 경우(즉, 나누어 떨어지는 경우), 또는 부동 소수점 타입 duration 간 변환은 묵시적으로 duration의 생성자를 통해 변환되므로 duration_cast가 따로 필요 없다.

duration<int, std::milli> a{10};
duration<int> b = a; // Error : (1 / 1000) % (1 / 1) != 0

minutes c{10};
hours d = c; // Error : (1 / 60) % (1 / 3600) != 0

duration<int, std::milli> e{10};
duration<float> f = e; // OK : no loss

duration<float, std::milli> g{10};
duration<double> h = g; // OK : no loss

hours i{10};
minutes j = i; // OK : (1 / 3600) % (1 / 60) == 0

 

[참고]

- https://youtu.be/P32hvk8b13M


  1. std::ratio 클래스는 유리수를 나타내기 위한 템플릿 클래스로, 첫 번째, 두 번째 템플릿 파라미터는 각각 분자와 분모의 값을 나타낸다. chrono::duration은 이 클래스를 단위 간 변환을 위해 사용한다.

    template<std::intmax_t Num, std::intmax_t Denom = 1> 
    class ratio;

    <ratio> 헤더 파일에는 다음과 같이 자주 사용되는 단위들이 미리 정의되어 있다.

    // <ratio>
    using nano  = ratio<1, 1000000000>;
    using micro = ratio<1, 1000000>;
    using milli = ratio<1, 1000>;
    ...

    [본문으로]