7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

アプリを.NET 5に移行して、nullableにした話

Last updated at Posted at 2020-12-15

概要

この記事はC# Advent Calendar 2020の16日目の記事です。
これはもともと.NET Core 3.1で作ってあったWPFアプリを.NET 5に移行して、nullableなどのC#8, 9の言語機能を実装してみた記録です。変更点はWPFとはあまり関係ないので、C#であれば他のフレームワークにも参考になるかもしれません。
WPFアプリの中身はファイルリネーマーです。アプリの詳細はこちらで。コード行数850行ぐらい、クラス数40個ぐらいのサンプルアプリに毛の生えたぐらいのコードサイズです。
修正点はたくさんありますので、全部の修正はCommit履歴の2020/12/13日のCommitを見てください。

.NET 5に変更

まずは.NET 5

FileRenamerDiff.csproj
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
...
  </PropertyGroup>

👇

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net5.0-windows</TargetFramework>
...
  </PropertyGroup>

何かエラーするかと思いきや特に問題はないですね。.NET Frameworkからだったら何か起きたかも?

nullableを有効化

次にnullableを有効化します。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net5.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
...
  </PropertyGroup>

警告がたくさん表示されます。nullableという名前とは裏腹にnullに対して非寛容なコーディングを心がける必要があるようです。

image.png

これらを1つずつ潰していきます。警告ついたところにすべて ? をつけて回っても良いいですが、それではnullableにした意味がありませんので、できる限りそれ以外の方法でやります。

その中で興味深かった点をいくつかあげてみます。

null許容型なのにnullじゃない?

指定したファイルパスのディレクトリを作成する処理で、以下のような警告が出ました。

image.png

これは指定したパスのディレクトリパスを取得する Path.GetDirectoryName() がnullの可能性があるstring?なのに対し、ディレクトリを作成する Directory.CreateDirectory() はnullを許容しないために発生します。

そこで、以下のように変更しました。

string? dirPath = Path.GetDirectoryName(settingFilePath);
if (!String.IsNullOrWhiteSpace(dirPath))
    Directory.CreateDirectory(dirPath);

すると警告が消えました。ただ Directory.CreateDirectory() に渡しているのは変わらず string? のままです。しかし、コンパイラがフロー解析することで、ifの内側ではnullでないことが保証されているわけです。

image.png

Where(!=null)でもnullかも?

以下のようにnull非許容型の配列にnullの可能性のある要素を入れると警告されます。
FileInfoは例として使っただけで、classならどれでも同じです。

FileInfo[] replacePatterns = new[] { new FileInfo("test1.txt"), (FileInfo)null, new FileInfo("test2.txt") };

では、nullの要素をWhereでフィルタリングすれば、コンパイラさんも分かってくれるでしょ!としても。。。

FileInfo[] replacePatterns = new[] { new FileInfo("test1.txt"), (FileInfo)null, new FileInfo("test2.txt") }
    .Where(a => a != null)
    .ToArray();

コンパイラには伝わらないようです。

image.png

そこで以下の記事を参考にnullの可能性を無くすWhere、中身は OfType() の拡張メソッドを作りました。
nullを取り除くLINQ Where [null許容参照型]
OfType() を直接使っても良いですが、型を書くのを省略できますし、意味も明確です。

public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source) where T : class =>
    source.OfType<T>();

これで警告がでなくなりました。

FileInfo[] replacePatterns = new[] { new FileInfo("test1.txt"), (FileInfo)null, new FileInfo("test2.txt") }
    .WhereNotNull()
    .ToArray();

ReactivePropertyは生成時はnullだけど、生成後はnullでない?

ReactivePropertyはIObservableから生成しようとすると、nullの可能性があると警告が出ます。

ReadOnlyReactiveProperty<SettingAppViewModel> settingVM1 = model
    .ObserveProperty(x => x.Setting)
    .Select(x => new SettingAppViewModel(x))
    .ToReadOnlyReactiveProperty();

ReadOnlyReactiveProperty<SettingAppViewModel?> にしたら?という警告が出ますが、 modelSetting もnullの可能性がないため、settingVM1 もそのValueもnullになることはありません。
コンパイラもmodelSetting もnullの可能性がないことは把握していますが、
ToReadOnlyReactiveProperty() の返り値がnullの可能性があるとみなしているようです。

型を明示的に非許容型にすれば、警告は無くなります。

ReadOnlyReactiveProperty<SettingAppViewModel> settingVM1 = model
    .ObserveProperty(x => x.Setting)
    .Select(x => new SettingAppViewModel(x))
    .ToReadOnlyReactiveProperty<SettingAppViewModel>();

一方で確実にValueの初期値はnullになる以下のようなコードは警告が出ません。

ReactiveProperty<SettingAppViewModel> settingVM2 = new ReactiveProperty<SettingAppViewModel>();
SettingAppViewModel v2 = settingVM2.Value;

なかなか不思議な挙動ですが、今後のReactivePropertyの更新などで変わってくる可能性はありますね。

標準クラスライブラリでもnullableでないのもある?

上に書いたPath.GetDirectoryName()は定義をみると、返り値の型に?が付いていますので、null許容型であることがわかります。
逆にPath.GetFullPath()はnullでないことも保証されています。

public static string? GetDirectoryName(string? path);
...
public static string GetFullPath(string path);

しかし、以下のようなコードは警告が出ませんが、実行するとNullReferenceExceptionが発生します。

var tb = new TextBox();
bool isSealed = tb.Parent.IsSealed;

どこにも配置されていないTextBoxのParentはnullなので、これは間違い。

image.png

この2つの違いはライブラリ自体がnullableかどうかですが、それを知るためにはクラス定義を見に行き、#nullable enableがあるか確認する必要があるようです。

#region アセンブリ System.Runtime, Version=5.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
// C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\5.0.0\ref\net5.0\System.Runtime.dll
#endregion

#nullable enable
...
namespace System.IO
{
...
    public static class Path
    {
...
       public static string? GetDirectoryName(string? path);
#region アセンブリ PresentationFramework, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
// C:\Program Files\dotnet\packs\Microsoft.WindowsDesktop.App.Ref\5.0.0\ref\net5.0\PresentationFramework.dll
#endregion
...
namespace System.Windows.Controls
{
...
    public class TextBox : TextBoxBase, IAddChild

new 型推論

var の逆でコンストラクタの型名を省略できるものです。引数ありでも省略できます。
DictonaryやReactivePropertyなどのジェネリック型を使用する場合などは記述がスッキリして良いですね。

ReactivePropertySlim<AppMessage> MessageEvent { get; } = new ReactivePropertySlim<AppMessage>();

ReplacePattern ToReplacePattern() => new ReplacePattern(tp, rt, asEx);

👇

ReactivePropertySlim<AppMessage> MessageEvent { get; } = new();

ReplacePattern ToReplacePattern() => new (tp, rt, asEx);

上のパターンはよくサンプルとして挙げられますが、変わった例ではタプルを使わなくなったというケースがありました。

元々はこんな感じに、引数を指定してnewした、クラスの配列を生成しているところがありました。

CommonPattern[] ReplacePatterns1 =
    new[]
    {
        new CommonPattern("commentA","targetA", "replaceA"),
        new CommonPattern("commentB","targetB", "replaceB"),
        new CommonPattern("commentC","targetC", "replaceC"),
...
    };

これの型名を何回も書くのが、面倒だなと思って、列挙時はタプルにして、あとからクラスに変換していました。

CommonPattern[] ReplacePatterns2 =
    new (string comment, string target, string replace)[]
    {
        ("commentA","targetA", "replaceA"),
        ("commentB","targetB", "replaceB"),
        ("commentC","targetC", "replaceC"),
...
    }
    .Select(a => new CommonPattern(a.comment, a.target, a.replace))
    .ToArray();

new 型推論が使えるなら、最初の型名が冗長という問題も無くなりますので、タプルなしで直接newした要素の配列を生成するようにしました。

CommonPattern[] ReplacePatterns3 =
    new CommonPattern[]
    {
        new("commentA","targetA", "replaceA"),
        new("commentB","targetB", "replaceB"),
        new("commentC","targetC", "replaceC"),
    };

パターンマッチング

否定の条件を!ではなくてnotで表現する。ちょっとVBっぽい。
dButtonBaseでなければreturnするコードです。

if (!(d is ButtonBase button))
   return;

👇

if (d is not ButtonBase button)
   return;

より複雑なケースでは以下のようになりました。
parentのVisualTree上の親を順番に遡り、nullDataGridCellsPresenter以外のItemsControlに到達したら、whileから抜けるコードです。

while (parent != null && !(parent is ItemsControl) || parent is DataGridCellsPresenter)
{
   parent = VisualTreeHelper.GetParent(parent);
...
}

👇

while (parent is not null and not ItemsControl or DataGridCellsPresenter)
{
   parent = VisualTreeHelper.GetParent(parent);
...
}

比較対象のparentを書く回数が減りました。
is not null and notは見慣れればわかりやすいのかな?ちょっとまだ頭が追いつかないです。

レコード型

いくつかのプロパティが全て読み取り専用のクラスをレコード型に変更しました。

/// <summary>   
/// よく使う設定パターン集
/// </summary>
public class CommonPattern
{
    /// <summary>
    /// 置換される対象のパターン
    /// </summary>
    public string TargetPattern { get; }

    /// <summary>
    /// 置換後文字列
    /// </summary>
    public string ReplaceText { get; }

    /// <summary>
    /// サンプル入力例
    /// </summary>
    public string SampleInput { get; }
    
    /// <summary>
    /// サンプル出力例
    /// </summary>
    public string SampleOutput { get; }

    public CommonPattern(string targetPattern, string replaceText, string sampleInput)
    {
        this.TargetPattern = targetPattern;
        this.ReplaceText = replaceText;
        this.SampleInput = sampleInput;

        var pattern = ToReplacePattern();
        this.SampleOutput = pattern.ToReplaceRegex()?.Replace(SampleInput) ?? String.Empty;
    }

    /// <summary>
    /// 置換パターンへの変換
    /// </summary>
    public ReplacePattern ToReplacePattern() => new(TargetPattern, ReplaceText);
}

👇

/// <summary>
/// よく使う設定パターン集
/// </summary>
/// <param name="TargetPattern">置換される対象のパターン</param>
/// <param name="ReplaceText">置換後文字列</param>
/// <param name="SampleInput">サンプル入力例</param>
public record CommonPattern(string TargetPattern, string ReplaceText, string SampleInput)
{
    /// <summary>
    /// 置換パターンへの変換
    /// </summary>
    public ReplacePattern ToReplacePattern() => new(TargetPattern, ReplaceText, AsExpression);

    /// <summary>
    /// サンプル出力例
    /// </summary>
    public string SampleOutput { get; } = new ReplacePattern(TargetPattern, ReplaceText, AsExpression)
        .ToReplaceRegex()?.Replace(SampleInput)
        ?? String.Empty;
}

かなり簡潔になりましたね。ただコンストラクタを自分で書かなくなったため、get-onlyプロパティの初期化はプロパティ初期化子にのみ限られます。したがってメソッドも使えません。

またプロパティに対してXMLコメントをつけることもできません。
recordキーワードの上あたりに引数のコメントをつけられますが、これはプロパティのコメントではないので、プロパティのコメントとして表示はされません。

いくつかのクラスをレコードに変更しましたが、元々get-onlyのプロパティだけのクラスであれば、使用側は何も変えなくても大丈夫でした。

総評

C#8, 9の知識がうっすらある状態で1日かければ修正することができました。
最も大変だったのはnullableですが、これもとりあえず?つけとく、みたいな対応ならすぐに終わると思います。
ただ1日やっただけでも、nullの可能性がないことが保証された状態でのコーディングは心理的安全性が高いことが分かります。そうするとよりnullの可能性を狭めたくなってきます。
new 型推論やレコード型など記述が簡潔になる構文が増えるのは嬉しいですね。パターンマッチングは名前通りいろいろなパターンがあって把握しきれていませんが、複雑な条件分岐などで威力を発揮しそうです。

参考

https://ufcpp.net/study/csharp/cheatsheet/ap_ver8/
https://ufcpp.net/study/csharp/cheatsheet/ap_ver9/

環境

VisualStudio 2019
C# 9
.NET 5
ReactiveProperty 7.5.1

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?