[C#] DateTime を指定した単位に切り上げ、切り捨て、四捨五入する

2021-12-28 (火)

DateTime をミリ秒未満など指定した単位へ切り捨てや、10分単位など任意の単位へ丸める方法。

環境

  • .NET 6.0.101
  • C# 10.0
  • Visual Studio 2022 Version 17.0.4
  • ReSharper 2021.3.2
  • Windows 10 Pro 64bit 21H1 19043.1415

実装

拡張メソッドでの実装

internal static class DateTimeExtensions
{
    private const long TicksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000;

    public static DateTime RoundUpToDay(this DateTime value) => value.RoundUpCore(TimeSpan.TicksPerDay);
    public static DateTime RoundUpToHour(this DateTime value) => value.RoundUpCore(TimeSpan.TicksPerHour);
    public static DateTime RoundUpToMinute(this DateTime value) => value.RoundUpCore(TimeSpan.TicksPerMinute);
    public static DateTime RoundUpToSecond(this DateTime value) => value.RoundUpCore(TimeSpan.TicksPerSecond);
    public static DateTime RoundUpToMillisecond(this DateTime value) => value.RoundUpCore(TimeSpan.TicksPerMillisecond);
    public static DateTime RoundUpToMicrosecond(this DateTime value) => value.RoundUpCore(TicksPerMicrosecond);
    public static DateTime RoundUp(this DateTime value, TimeSpan interval) => value.RoundUpCore(interval.Ticks);

    public static DateTime RoundDownToDay(this DateTime value) => value.RoundDownCore(TimeSpan.TicksPerDay);
    public static DateTime RoundDownToHour(this DateTime value) => value.RoundDownCore(TimeSpan.TicksPerHour);
    public static DateTime RoundDownToMinute(this DateTime value) => value.RoundDownCore(TimeSpan.TicksPerMinute);
    public static DateTime RoundDownToSecond(this DateTime value) => value.RoundDownCore(TimeSpan.TicksPerSecond);
    public static DateTime RoundDownToMillisecond(this DateTime value) => value.RoundDownCore(TimeSpan.TicksPerMillisecond);
    public static DateTime RoundDownToMicrosecond(this DateTime value) => value.RoundDownCore(TicksPerMicrosecond);
    public static DateTime RoundDown(this DateTime value, TimeSpan interval) => value.RoundDownCore(interval.Ticks);

    public static DateTime RoundAwayFromZeroToDay(this DateTime value) => value.RoundAwayFromZeroCore(TimeSpan.TicksPerDay);
    public static DateTime RoundAwayFromZeroToHour(this DateTime value) => value.RoundAwayFromZeroCore(TimeSpan.TicksPerHour);
    public static DateTime RoundAwayFromZeroToMinute(this DateTime value) => value.RoundAwayFromZeroCore(TimeSpan.TicksPerMinute);
    public static DateTime RoundAwayFromZeroToSecond(this DateTime value) => value.RoundAwayFromZeroCore(TimeSpan.TicksPerSecond);
    public static DateTime RoundAwayFromZeroToMillisecond(this DateTime value) => value.RoundAwayFromZeroCore(TimeSpan.TicksPerMillisecond);
    public static DateTime RoundAwayFromZeroToMicrosecond(this DateTime value) => value.RoundAwayFromZeroCore(TicksPerMicrosecond);
    public static DateTime RoundAwayFromZero(this DateTime value, TimeSpan interval) => value.RoundAwayFromZeroCore(interval.Ticks);

    private static DateTime RoundUpCore(this DateTime value, long interval)
    {
        return value.Ticks % interval is var overflow and > 0
            ? value.AddTicks(interval - overflow)
            : value;
    }

    private static DateTime RoundDownCore(this DateTime value, long interval)
    {
        return value.AddTicks(-(value.Ticks % interval));
    }

    private static DateTime RoundAwayFromZeroCore(this DateTime value, long interval)
    {
        var ticks = value.Ticks + interval >> 1;
        return new DateTime(ticks - ticks % interval, value.Kind);
    }
}

使用方法

1. 切り上げ

// 切り上げた桁未満は 0 埋めになる
var d = new DateTime(2021, 11, 11, 11, 11, 11).AddTicks(1111111);
                          // 2021-11-11 11:11:11.1111111
d.RoundUpToDay();         // 2021-11-12 00:00:00.0000000
d.RoundUpToHour();        // 2021-11-11 12:00:00.0000000
d.RoundUpToMinute();      // 2021-11-11 11:12:00.0000000
d.RoundUpToSecond();      // 2021-11-11 11:11:12.0000000
d.RoundUpToMillisecond(); // 2021-11-11 11:11:11.1120000
d.RoundUpToMicrosecond(); // 2021-11-11 11:11:11.1111120

// AddTicks(1) (=100 ナノ秒) が含まれると必ず切り上げになる
var d = new DateTime(2021, 11, 11, 0, 0, 0).AddTicks(1);
                          // 2021-11-11 00:00:00.0000001
d.RoundUpToDay();         // 2021-11-12 00:00:00.0000000
d.RoundUpToHour();        // 2021-11-11 01:00:00.0000000
d.RoundUpToMinute();      // 2021-11-11 00:01:00.0000000
d.RoundUpToSecond();      // 2021-11-11 00:00:01.0000000
d.RoundUpToMillisecond(); // 2021-11-11 00:00:00.0010000
d.RoundUpToMicrosecond(); // 2021-11-11 00:00:00.0000010

2. 切り捨て

// 切り捨てた桁未満は 0 になる
var d = new DateTime(2021, 11, 11, 11, 11, 11).AddTicks(1111111);
                            // 2021-11-11 11:11:11.1111111
d.RoundDownToDay();         // 2021-11-11 00:00:00.0000000
d.RoundDownToHour();        // 2021-11-11 11:00:00.0000000
d.RoundDownToMinute();      // 2021-11-11 11:11:00.0000000
d.RoundDownToSecond();      // 2021-11-11 11:11:11.0000000
d.RoundDownToMillisecond(); // 2021-11-11 11:11:11.1110000
d.RoundDownToMicrosecond(); // 2021-11-11 11:11:11.1111110

3. 四捨五入

// 切り上げ
var d = new DateTime(2021, 11, 11, 12, 30, 30).AddTicks(500_500_5);
                                    // 2021-11-11 12:30:30.5005005
d.RoundAwayFromZeroToDay();         // 2021-11-12 00:00:00.0000000
d.RoundAwayFromZeroToHour();        // 2021-11-11 13:00:00.0000000
d.RoundAwayFromZeroToMinute();      // 2021-11-11 12:31:00.0000000
d.RoundAwayFromZeroToSecond();      // 2021-11-11 12:30:31.0000000
d.RoundAwayFromZeroToMillisecond(); // 2021-11-11 12:30:30.5010000
d.RoundAwayFromZeroToMicrosecond(); // 2021-11-11 12:30:30.5005010

// 切り捨て
var d = new DateTime(2021, 11, 11, 11, 29, 29).AddTicks(499_499_4);
                                    // 2021-11-11 11:29:29.4994994
d.RoundAwayFromZeroToDay();         // 2021-11-11 00:00:00.0000000
d.RoundAwayFromZeroToHour();        // 2021-11-11 11:00:00.0000000
d.RoundAwayFromZeroToMinute();      // 2021-11-11 11:29:00.0000000
d.RoundAwayFromZeroToSecond();      // 2021-11-11 11:29:29.0000000
d.RoundAwayFromZeroToMillisecond(); // 2021-11-11 11:29:29.4990000
d.RoundAwayFromZeroToMicrosecond(); // 2021-11-11 11:29:29.4994990

時分秒の四捨五入の境界値は以下の通り

切り上げ切り捨て進数
11 以下12 以上24
29 以下30 以上60
29 以下30 以上60
秒未満4 以下5 以上10

4. 任意の単位で丸める

TimeSpan で任意のインターバルを指定する。

// 0.01 秒単位に丸める
var interval = TimeSpan.FromMilliseconds(10);
var d = new DateTime(2021, 11, 11, 11, 11, 11).AddTicks(123_456_7);
                               // 2021-11-11 11:11:11.1234567
d.RoundDown(interval);         // 2021-11-11 11:11:11.1200000
d.RoundUp(interval);           // 2021-11-11 11:11:11.1300000
d.RoundAwayFromZero(interval); // 2021-11-11 11:11:11.1200000

// 10 分単位に丸める
var interval = TimeSpan.FromMinutes(10);
var d = new DateTime(2021, 11, 11, 0, 34, 59);
                               // 2021-11-11 00:34:59.0000000
d.RoundDown(interval);         // 2021-11-11 00:30:00.0000000
d.RoundUp(interval);           // 2021-11-11 00:40:00.0000000
d.RoundAwayFromZero(interval); // 2021-11-11 00:30:00.0000000

自分秒の進数で割り切れる値で指定しないと、期待した結果にならない可能性がある。
x は以下のような値を指定すると良い。

単位メソッドx の値
TimeSpan.FromHours(x)2, 3, 4, 6
TimeSpan.FromMinutes(x)2, 5, 10, 15, 20, 30 など
TimeSpan.FromSeconds(x)2, 5, 10, 15, 20, 30 など
秒未満TimeSpan.FromMilliseconds(x)5 以上
その他TimeSpan.FromDays(x)
TimeSpan.FromTicks(x)
基本使わない

注意

1. Interval に TimeSpan.Zero を指定すると DivideByZeroException 例外が発生する

interval.Ticks == 0 の引数チェックの実装はしていないので、対応したければ修正が必要になる。

// DivideByZeroException が発生
new DateTime(2021, 11, 11).RoundDown(TimeSpan.FromMilliseconds(0);

2. DateTime.MaxValue を超える切り上げは ArgumentOutOfRangeException 例外が発生する

DateTime.MaxValue を超える場合に DateTime.MaxValue として返すようにしたければ、コード修正が必要になる。
対応するのであれば、DateTime.MaxTicks を超える場合に、DateTime.MaxValue として返すようにすれば良いと思う。今回の例では対応しない。

// ArgumentOutOfRangeException が発生
new DateTime(9999,12,31,0,0,1).RoundUpToDay();

3. C# 8.0 以前では実装に修正が必要

RoundUpCore()C# 9.0 のパターンマッチング を使用しているため、C# 8.0 以前の場合は修正が必要になる。

private static DateTime RoundUpCore(this DateTime value, long interval)
{
    // return value.Ticks % interval is var overflow and > 0
    //     ? value.AddTicks(interval - overflow)
    //     : value;

    // C# 8.0 以前に対応
    var overflow = value.Ticks % interval;
    return overflow == 0 ? value : value.AddTicks(interval - overflow);
}

上記の overflow == 0 では、ReSharper 2021.3.2 で Code Inspection: Expression is always ’true’ or always ‘false’ が表示されたので、パターンマッチングに修正した。

ちなみに、overflow.Equals(0)だと問題ない。

Rider でも同様なので、ReSharper に Feedback は送ってみた。

実装の悩み

1. RoundUp, RoundDown というメソッド名

Math.Ceiling()Math.Floor() にメソッド名を合わせると本当は、

  • RoundUp -> Ceiling
  • RoundDown -> Floor

にすべきかなと思う。
だけど、C#慣れしていない人や IntelliSense による入力補完を考えると Round に統一した方が分かりやすいと思ったので、RoundUp, RoundDown にした。

2. RoundAwayFromZero というメソッド名

名前が長くなってしまうが Round ではなく RoundAwayFromZero というメソッド名にした。
これは Math.Round() の四捨五入がデフォルトでは MidpointRounding.ToEven (銀行丸め) であり、MidpointRounding.RoundAwayFromZero であることを明確にするため、メソッド名に付けた。

3. その他の実装方法とパフォーマンス

他にも実装方法はいくつかあるので調べてみる。

切り上げ

public static DateTime RoundUp1(this DateTime value, TimeSpan interval)
{
    return value.Ticks % interval.Ticks is var overflow and > 0
        ? value.AddTicks(interval.Ticks - overflow)
        : value;
}

public static DateTime RoundUp2(this DateTime value, TimeSpan interval)
{
    return new DateTime((value.Ticks + interval.Ticks - 1) / interval.Ticks * interval.Ticks, value.Kind);
}

切り捨て

public static DateTime RoundDown1(this DateTime value, TimeSpan interval)
{
    return value.AddTicks(-(value.Ticks % interval.Ticks));
}

public static DateTime RoundDown2(this DateTime value, TimeSpan interval)
{
    return new DateTime(((value.Ticks + interval.Ticks) / interval.Ticks - 1) * interval.Ticks, value.Kind);
}

public static DateTime RoundDown3(this DateTime value, TimeSpan interval)
{
    var ticks = value.Ticks / interval.Ticks;
    return new DateTime(ticks * interval.Ticks, value.Kind);
}

四捨五入

public static DateTime Round1(this DateTime value, TimeSpan interval)
{
    var halfIntervalTicks = interval.Ticks >> 1;
    return value.AddTicks(halfIntervalTicks - (value.Ticks + halfIntervalTicks) % interval.Ticks);
}

public static DateTime Round2(this DateTime value, TimeSpan interval)
{
    var ticks = (value.Ticks + interval.Ticks >> 1) / interval.Ticks;
    return new DateTime(ticks * interval.Ticks, value.Kind);
}

public static DateTime Round3(this DateTime value, TimeSpan interval)
{
    var ticks = value.Ticks + interval.Ticks >> 1;
    return new DateTime(ticks - ticks % interval.Ticks, value.Kind);
}

性能も何回か計測してみた上で

  • RoundUp は RoundUp1 が少し高速。
  • RoundDown は性能誤差なので、実装は RoundDown1 がシンプル。
  • Round は性能誤差だけど、Round3 が少し高速な傾向。
  • interval.Ticks / 2interval.Ticks >> 1 の方が少し高速。
  • new DateTime() コンストラクタで ticks を使わない方法では、約20倍以上遅い。
  • new DateTime() コンストラクタだと、引数に Kind を指定 し忘れないように注意が必要。(DateTimeKind.Unspecified になるため)
  • new DateTime() コンストラクタだと kind の引数判定が入るが、逆に AddTicks() だと Ticks + value が入る。
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1415 (21H1/May2021Update)
AMD Ryzen 9 3900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK=6.0.101
  [Host]   : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT
  .NET 6.0 : .NET 6.0.1 (6.0.121.56705), X64 RyuJIT

Job=.NET 6.0  Runtime=.NET 6.0
MethodvalueintervalMeanErrorStdDevRatioRatioSD
RoundUp111/11/2021 11:11:111.00:00:004.119 ns0.0697 ns0.0652 ns1.000.00
RoundUp211/11/2021 11:11:111.00:00:004.183 ns0.0379 ns0.0336 ns1.010.01
RoundDown111/11/2021 11:11:111.00:00:004.085 ns0.0431 ns0.0403 ns1.000.00
RoundDown211/11/2021 11:11:111.00:00:004.067 ns0.0200 ns0.0187 ns1.000.01
RoundDown311/11/2021 11:11:111.00:00:004.039 ns0.0450 ns0.0421 ns0.990.02
Round111/11/2021 11:11:111.00:00:004.051 ns0.0127 ns0.0106 ns1.000.00
Round211/11/2021 11:11:111.00:00:003.839 ns0.0229 ns0.0214 ns0.950.00
Round311/11/2021 11:11:11*1.00:00:003.817 ns0.0274 ns0.0242 ns0.940.01
RoundUp112/31/2021 23:59:5900:00:00.00100006.999 ns0.0326 ns0.0305 ns1.000.00
RoundUp212/31/2021 23:59:5900:00:00.00100007.034 ns0.0392 ns0.0367 ns1.010.01
RoundDown112/31/2021 23:59:5900:00:00.00100007.088 ns0.0769 ns0.0682 ns1.000.00
RoundDown212/31/2021 23:59:5900:00:00.00100007.095 ns0.1035 ns0.0968 ns1.000.02
RoundDown312/31/2021 23:59:5900:00:00.00100007.149 ns0.0921 ns0.0861 ns1.010.02
Round112/31/2021 23:59:5900:00:00.00100007.200 ns0.0920 ns0.0861 ns1.000.00
Round212/31/2021 23:59:5900:00:00.00100007.011 ns0.1045 ns0.0977 ns0.970.01
Round312/31/2021 23:59:5900:00:00.00100006.771 ns0.0027 ns0.0021 ns0.940.01
Benchmarkコード例
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<DateTimeRoundBenchmark>();

[SimpleJob(RuntimeMoniker.Net60)]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
public class DateTimeRoundBenchmark
{
    public IEnumerable<object[]> DateTimes()
    {
        yield return new object[] { new DateTime(2021, 12, 31, 23, 59, 59).AddTicks(999_999_9), new TimeSpan(TimeSpan.TicksPerMillisecond) };
        yield return new object[] { new DateTime(2021, 11, 11, 11, 11, 11).AddTicks(111_111_1), new TimeSpan(TimeSpan.TicksPerDay) };
    }

    // RoundUp

    [Benchmark(Baseline = true)]
    [BenchmarkCategory("RoundUp")]
    [ArgumentsSource(nameof(DateTimes))]
    public DateTime RoundUp1(DateTime value, TimeSpan interval)
    {
        return value.RoundUp1(interval);
    }

    [Benchmark]
    [BenchmarkCategory("RoundUp")]
    [ArgumentsSource(nameof(DateTimes))]
    public DateTime RoundUp2(DateTime value, TimeSpan interval)
    {
        return value.RoundUp2(interval);
    }

    // RoundDown

    [Benchmark(Baseline = true)]
    [BenchmarkCategory("RoundDown")]
    [ArgumentsSource(nameof(DateTimes))]
    public DateTime RoundDown1(DateTime value, TimeSpan interval)
    {
        return value.RoundDown1(interval);
    }

    [Benchmark]
    [BenchmarkCategory("RoundDown")]
    [ArgumentsSource(nameof(DateTimes))]
    public DateTime RoundDown2(DateTime value, TimeSpan interval)
    {
        return value.RoundDown2(interval);
    }

    [Benchmark]
    [BenchmarkCategory("RoundDown")]
    [ArgumentsSource(nameof(DateTimes))]
    public DateTime RoundDown3(DateTime value, TimeSpan interval)
    {
        return value.RoundDown3(interval);
    }

    // Round

    [Benchmark(Baseline = true)]
    [BenchmarkCategory("Round")]
    [ArgumentsSource(nameof(DateTimes))]
    public DateTime Round1(DateTime value, TimeSpan interval)
    {
        return value.Round1(interval);
    }

    [Benchmark]
    [BenchmarkCategory("Round")]
    [ArgumentsSource(nameof(DateTimes))]
    public DateTime Round2(DateTime value, TimeSpan interval)
    {
        return value.Round2(interval);
    }

    [Benchmark]
    [BenchmarkCategory("Round")]
    [ArgumentsSource(nameof(DateTimes))]
    public DateTime Round3(DateTime value, TimeSpan interval)
    {
        return value.Round3(interval);
    }
}

internal static class DateTimeExtensions
{
    // RoundUp

    public static DateTime RoundUp1(this DateTime value, TimeSpan interval)
    {
        return value.Ticks % interval.Ticks is var overflow and > 0
            ? value.AddTicks(interval.Ticks - overflow)
            : value;
    }

    public static DateTime RoundUp2(this DateTime value, TimeSpan interval)
    {
        return new DateTime((value.Ticks + interval.Ticks - 1) / interval.Ticks * interval.Ticks, value.Kind);
    }

    // RoundDown

    public static DateTime RoundDown1(this DateTime value, TimeSpan interval)
    {
        return value.AddTicks(-(value.Ticks % interval.Ticks));
    }

    public static DateTime RoundDown2(this DateTime value, TimeSpan interval)
    {
        return new DateTime(((value.Ticks + interval.Ticks) / interval.Ticks - 1) * interval.Ticks, value.Kind);
    }

    public static DateTime RoundDown3(this DateTime value, TimeSpan interval)
    {
        var ticks = value.Ticks / interval.Ticks;
        return new DateTime(ticks * interval.Ticks, value.Kind);
    }

    // Round

    public static DateTime Round1(this DateTime value, TimeSpan interval)
    {
        var halfIntervalTicks = interval.Ticks >> 1;
        return value.AddTicks(halfIntervalTicks - (value.Ticks + halfIntervalTicks) % interval.Ticks);
    }

    public static DateTime Round2(this DateTime value, TimeSpan interval)
    {
        var ticks = (value.Ticks + interval.Ticks >> 1) / interval.Ticks;
        return new DateTime(ticks * interval.Ticks, value.Kind);
    }

    public static DateTime Round3(this DateTime value, TimeSpan interval)
    {
        var ticks = value.Ticks + interval.Ticks >> 1;
        return new DateTime(ticks - ticks % interval.Ticks, value.Kind);
    }
}

感謝

2021-12-28 (火)