【トラブル回避】端末依存のC#処理に注意！

https://qiita.com/advent-calendar/2021/sumzap

本記事は、[サムザップ Advent Calendar 2021](https://qiita.com/advent-calendar/2021/sumzap)の12/7の記事です。

## はじめに
C#の一部APIには、端末の設定によって結果が変わる挙動をするものがあります。
今回はそんな危うい挙動をする処理と、それを端末の設定に依存させないで行う方法を紹介したいと思います。

## 端末の言語設定に依存した処理

```csharp
float n = float.Parse("1.5");
```

これは`1.5`という文字列をパースして`float`へ変換する何の変哲もないコードですが、
端末が特定の言語に設定されている場合、以下の例外が投げられて処理は失敗してしまいます。

```
FormatException: Input string was not in a correct format.
```

これは小数点の表現がカルチャによって異なることと、デフォルトではカルチャは端末の言語設定を参照していることに依るものです。

> [ws][sign] [integral-digits[,]]integral-digits[.[fractional-digits]][e[sign]exponential-digits][ws]
>  
> .	A culture-specific decimal point symbol.
[Single.Parse Method (System) | Microsoft Docs](https://docs.microsoft.com/en-us/dotnet/api/system.single.parse?view=netstandard-2.0#System_Single_Parse_System_String_)

例えば端末の言語がフランスに設定されている場合、小数点はカンマ（`,`）で表現されるため、
`1.5`を浮動小数点数として正しくパースすることが出来ません。

この言語設定への依存は、明示的にカルチャを指定することで解消出来ます。
以下のコードでは、`float.Parse`メソッドの第2引数へ特定の国/地域に依存しないカルチャ[^1]を指定しています。

```csharp
float n = float.Parse("1.5", CultureInfo.InvariantCulture);
```

なお今回は`float`を例に挙げましたが、同じ浮動小数点数の`double`はもちろん、
`DateTime`, `DateTimeOffset`などのパースも端末の言語設定の影響を受けます。

[^1]: [CultureInfo.InvariantCulture プロパティ (System.Globalization) | Microsoft Docs](https://docs.microsoft.com/ja-jp/dotnet/api/system.globalization.cultureinfo.invariantculture?view=netstandard-2.0)

### Unity特有の事情

ちなみにUnityではAndroid向けにビルドしたアプリで、デフォルトのカルチャが`InvariantCulture`となるバグ[^2]がありました。
このバグは修正されており、Unity 2020.1.5f1以降ではちゃんと端末に設定された言語の`CultureInfo`オブジェクトが返されます。

従って同じAndroid端末・同じ言語設定（例では日本語）で以下のコードを実行した場合、

```
Debug.Log(CultureInfo.CurrentCulture.DisplayName);
```

Unity 2019.4.15f1では、

```
Invariant Language (Invariant Country)
```

Unity 2020.3.14f1では、

```
Japanese (Japan)
```

と異なる結果が出力されます。

もしパース処理などでデフォルトのカルチャが`InvariantCulture`であることに依存していたコードがある場合、
古いUnityからバージョンアップをする際には注意が必要でしょう。

[^2]: [Unity Issue Tracker - [Android] System.Globalization.CultureInfo.CurrentCulture returns Culture Invariant when is being built on Android device](https://issuetracker.unity3d.com/issues/android-system-dot-globalization-dot-cultureinfo-dot-currentculture-returns-culture-invariant-when-is-being-built-on-android-device)


## 端末のタイムゾーンに依存した処理
少々わざとらしいコードですが、以下の処理も端末のタイムゾーンによって結果が異なります。

```cs
// toの指す日付とfromの差を出力する
var to   = DateTimeOffset.ParseExact("2021-12-03T12:00:00.0000000+09:00", "O", CultureInfo.InvariantCulture);
var from = DateTimeOffset.ParseExact("2021-12-01T00:00:00.0000000+09:00", "O", CultureInfo.InvariantCulture);
var delta = to.Date - from; // to.Dateでtoの日付部分を取得する
Debug.Log(delta.ToString());
```

例えば端末のタイムゾーンが日本標準時(+09:00)の場合は、想定した通りに2日と出力されますが、

```
2.00:00:00
```

中央ヨーロッパ時間(+01:00)に設定していた場合は、2日と8時間と出力されます。

```
2.08:00:00
```

何故この差異が生じるかというと、
`DateTimeOffset`の`Date`プロパティは元々のタイムゾーンが反映されないのと、

> The value of the DateTime.Kind property of the returned DateTime object is always DateTimeKind.Unspecified. It is not affected by the value of the Offset property.
[DateTimeOffset.Date Property (System) | Microsoft Docs](https://docs.microsoft.com/en-us/dotnet/api/system.datetimeoffset.date?view=netstandard-2.0)

`DateTime`と`DateTimeOffset`の減算は、`DateTime`が`DateTimeOffset`に変換されてから行われますが、
その変換の際に端末に設定されたタイムゾーンが採用されてしまうことによります。

> If the value of the DateTime.Kind property is DateTimeKind.Local or DateTimeKind.Unspecified, the date and time of the DateTimeOffset object is set equal to dateTime, and its Offset property is set equal to the offset of the local system's current time zone.
[DateTimeOffset.Implicit(DateTime to DateTimeOffset) Operator (System) | Microsoft Docs](https://docs.microsoft.com/en-us/dotnet/api/system.datetimeoffset.op_implicit?view=netstandard-2.0)

端末のタイムゾーンが中央ヨーロッパ時間(+01:00)のときの処理を一つ一つ追っていくと、

```csharp
var to   = DateTimeOffset.ParseExact("2021-12-03T12:00:00.0000000+09:00", "O", CultureInfo.InvariantCulture);
var from = DateTimeOffset.ParseExact("2021-12-01T00:00:00.0000000+09:00", "O", CultureInfo.InvariantCulture);
DateTime a = to.Date;      // 1. 2021-12-03 00:00:00, Kind=Unspecified
DateTimeOffset b = a;      // 2. 2021-12-03 00:00:00+01:00
TimeSpan delta = b - from; // 3. 2021-12-03 00:00:00+01:00 - 2021-12-01 00:00:00+09:00
```

1. `Date`プロパティで日付部分を表す`DateTime`インスタンスを取得（`DateTimeKind`は`Unspecified`）
1. `DateTime`インスタンスの`DateTimeOffset`への暗黙の変換、`DateTimeKind`は`Unspecified`なので、システムのタイムゾーン(+01:00)のオフセットが設定される
1. 差の計算の日時をUTCに換算すると、`2021-12-02 23:00:00 - 2021-11-30 15:00:00` となる

結果、差が2日と8時間になってしまっていることが分かります。


端末のタイムゾーン設定に依らず正しい結果を得たい場合は、
日付を取り出す際に元々のオフセットを指定して、明示的に`DateTimeOffset`をインスタンス化する必要があります。

```csharp
var to   = DateTimeOffset.ParseExact("2021-12-03T12:00:00.0000000+09:00", "O", CultureInfo.InvariantCulture);
var from = DateTimeOffset.ParseExact("2021-12-01T00:00:00.0000000+09:00", "O", CultureInfo.InvariantCulture);
var delta = new DateTimeOffset(to.Date, to.Offset) - from;
Debug.Log(delta.ToString());
```

これで意図した通りに2日と出力されます。

```
2.00:00:00
```


## 最後に

同じ環境で開発と動作確認を行っている場合、見逃されがちな端末の設定に依存した処理を紹介いたしました。
この記事が堅牢なシステムを構築するための助けになれば幸いです。


## 参考
 - [.NET のカルチャー依存 API 問題 | ++C++; // 未確認飛行 C ブログ](https://ufcpp.net/blog/2021/8/invariantculture/)
 - [.NET での数値文字列の解析 | Microsoft Docs](https://docs.microsoft.com/ja-jp/dotnet/standard/base-types/parsing-numeric#parsing-and-format-providers)
 - [DateTime と DateTimeOffset 間の変換 | Microsoft Docs](https://docs.microsoft.com/ja-jp/dotnet/standard/datetime/converting-between-datetime-and-offset)
 - [CultureInfo.CurrentCulture always returning Invariant Language (Invariant Country) - Unity Forum](https://forum.unity.com/threads/cultureinfo-currentculture-always-returning-invariant-language-invariant-country.554941/)