- 投稿日:2020-08-30T23:42:19+09:00
Moq 実装メモ
Moq 実装メモ
C# のテスト実装で
Moq
を使ったのでメモ。環境
- Visual Studio 2019 のプロジェクトテンプレート (NUnit テストプロジェクト (.NET Core)) を使用
- NuGet パッケージから
Moq
をインストールテスト対象のクラス実装
あくまで用例のための実装であり、 Moq が活きるテスト対象ではない。
Human.cspublic class Human { /// <summary> /// 苗字 /// </summary> private string FamilyName { get; } /// <summary> /// 名前 /// </summary> private string GivenName { get; } /// <summary> /// 年齢 /// </summary> public virtual int Age { get; } public Human(string familyName, string givenName, int age) { FamilyName = familyName; GivenName = givenName; Age = age; } /// <summary> /// フルネームを作成する。 /// </summary> /// <returns>フルネーム</returns> protected virtual string CreateFullName() { return $"{FamilyName} {GivenName}"; } /// <summary> /// 年齢付きのフルネームを作成する。 /// </summary> /// <returns>年齢付きのフルネーム</returns> public virtual string CreateFullNameWithAge() { return $"{CreateFullName()} {Age}"; } /// <summary> /// 年齢とその単位付きのフルネームを作成する。 /// </summary> /// <returns>年齢とその単位付きのフルネーム</returns> public virtual string CreateFullNameWithAge(string ageUnit) { return $"{CreateFullNameWithAge()}{ageUnit}"; } }注意点
テスト対象クラスを実装する上での注意点。
virual
モック対象のメソッドはオーバーライド可能な状態にする必要があるため
virtual
修飾子が必須となる。可視性もprotected
以上。以下はオーバーライド不能なメソッドをモック化しようとしたときのエラー文。 (一時的に
CreateFullName()
メソッドからvirtual
修飾子を除去して実行)System.NotSupportedException : Unsupported expression: mock => mock.CreateFullName() Non-overridable members (here: Human.CreateFullName) may not be used in setup / verification expressions.static
静的なメソッドやプロパティ等はモック化できない。
テスト実装
テスト実装項目で使用するクラスの構造。
Tests.cspublic class Tests { /// <summary> /// 苗字 (テスト用の固定値) /// </summary> private string FamilyName { get; set; } /// <summary> /// 名前 (テスト用の固定値) /// </summary> private string GivenName { get; set; } /// <summary> /// 年齢 (テスト用の固定値) /// </summary> private int Age { get; set; } /// <summary> /// テストの共通設定。 /// </summary> [SetUp] public void Setup() { FamilyName = "苗字"; GivenName = "名前"; Age = 20; } // 以下にテスト実装を記述 }基本
Moq を用いたモック化では
Moq.Mock<T>
クラスを用いる。
new
時は<T>
部にモック化対象のクラスを設定し、引数にはモック化対象のコンストラクタのメソッドを設定する。モック化されたオブジェクトは
Moq#Object
から呼び出す。var humanMock = new Mock<Human>(FamilyName, GivenName, Age) humanMock.Object.CreateFullNameWithAge();CallBase = false
Mock
クラスで最初につまずいたのはCallBase
プロパティの扱い。この値は、初期状態では
false
が設定されている。次の例では
CallBase
がfalse
のため、モック未設定のメソッドはnull
返却となる。 (メソッド自体も呼び出されない)CallBase_False[Test] public void CallBase_False() { var humanMock = new Mock<Human>(FamilyName, GivenName, Age); Assert.AreEqual(null, humanMock.Object.CreateFullNameWithAge()); }CallBase = true
次の例では
CallBase
がtrue
のため、モック未設定のメソッドは本来のメソッドが呼び出される。CallBase_True[Test] public void CallBase_True() { var humanMock = new Mock<Human>(FamilyName, GivenName, Age) { CallBase = true }; Assert.AreEqual($"{FamilyName} {GivenName} {Age}", humanMock.Object.CreateFullNameWithAge()); }その他
Moq#Object
はモック化対象と同一クラス。テストメソッド内で直接利用するだけでなく、引数やフィールドを通して間接的に利用することも可能。プロパティのモック化
Mock#SetupGet
メソッドのFunc
型引数からモック化対象のプロパティを指定し、その返却オブジェクトのReturns
メソッドからモック化対象の返却値を設定する。モック化されたプロパティは、設定された返却値を常に返すようになる。
OverrideProperty_Agepublic void OverrideProperty_Age() { var humanMock = new Mock<Human>(FamilyName, GivenName, Age) { CallBase = true }; humanMock.SetupGet(m => m.Age).Returns(9999); Assert.AreEqual($"{FamilyName} {GivenName} 9999", humanMock.Object.CreateFullNameWithAge()); Assert.AreEqual($"{FamilyName} {GivenName} 9999才", humanMock.Object.CreateFullNameWithAge("才")); }public なクラスメソッドのモック化
Setup(Func)
のモック化対象メソッドの引数が型指定のみ
Mock#Setup
メソッドのFunc
型引数からモック化対象のメソッドを指定し、その返却オブジェクトのReturns
メソッドからモック化対象の返却値を設定する。
Func
型引数で設定するメソッドにはMoq.It<T>
クラスを利用するなどして、モック化するメソッドを引数構成込みで明示的に設定する。OverrideMethod_CreateFullNameWithAgeUnit/// <summary> /// 引数付きの CreateFullNameWithAge を上書きする。 /// </summary> [Test] public void OverrideMethod_CreateFullNameWithAgeUnit() { var humanMock = new Mock<Human>(FamilyName, GivenName, Age) { CallBase = true }; humanMock.Setup(m => m.CreateFullNameWithAge(It.IsAny<string>())).Returns("上書き"); // Setup で指定した引数構成のものがモック化されている // 本来のメソッドは呼び出されず Returns で設定した戻り値が返却されている Assert.AreEqual("上書き", humanMock.Object.CreateFullNameWithAge("歳")); // Setup で指定したメソッドと同名だが、引数構成が異なる // モック化の対象外となるため、本来のメソッドが呼び出されている Assert.AreEqual($"{FamilyName} {GivenName} {Age}", humanMock.Object.CreateFullNameWithAge()); }
Setup(Func)
のモック化対象メソッドの引数が固定値
Moq.It<T>
を使わず、固定値を設定することもできる。OverrideMethod_CreateFullNameWithAgeUnit_2/// <summary> /// 引数付きの CreateFullNameWithAge を上書きする。 /// </summary> [Test] public void OverrideMethod_CreateFullNameWithAgeUnit_2() { var humanMock = new Mock<Human>(FamilyName, GivenName, Age) { CallBase = true }; humanMock.Setup(m => m.CreateFullNameWithAge("歳")).Returns("上書き"); // Setup で指定したメソッド名と引数が一致している // モック化により本来のメソッドが呼び出されず Returns で設定した戻り値が返却されている Assert.AreEqual("上書き", humanMock.Object.CreateFullNameWithAge("歳")); // Setup で指定したメソッドと同名、かつ、同じ型の引数構成だが、正確な設定値が異なる // モック化の対象外となるため、本来のメソッドが呼び出されている Assert.AreEqual($"{FamilyName} {GivenName} {Age}才", humanMock.Object.CreateFullNameWithAge("才")); }その他メモ
- 非同期メソッドは
ReturnsAsync
を利用可能。protected なクラスメソッドのモック化
引数なしメソッドのモック化
Moq#Protected
メソッドの返却オブジェクトのSetup<T>
メソッドからモック化の設定を行う。
<T>
部には戻り値の型を設定し、第 1 引数にモック化対象のメソッド名を設定する。Mock_ProtectedMethod/// <summary> /// protected メソッドのモック化。 /// </summary> [Test] public void Mock_ProtectedMethod() { var humanMock = new Mock<Human>(FamilyName, GivenName, Age) { CallBase = true }; humanMock.Protected().Setup<string>("CreateFullName").Returns("上書き"); // 上書きした戻り値が返却されている Assert.AreEqual($"上書き {Age}", humanMock.Object.CreateFullNameWithAge()); }引数ありメソッドのモック化
Setup<T>
の第 2 以降の引数にItExpr.IsAny<T>()
等を用いて引数構成を設定する。MockProtectedMethod_WithArgshumanMock.Protected().Setup<string>("GetAgeWithUnit", ItExpr.IsAny<string>()).Returns("上書き");メソッドの実行
通常のテスト実装で、モック化したメソッドが常にテストされないことは珍しいと思う。
Moq
の範囲ではないが、可視性がpublic
でないメソッドのリフレクション実行の方法も記述しておく。Run_ProtectedMethod[Test] public void Run_ProtectedMethod() { var human = new Human(FamilyName, GivenName, Age); Type type = human.GetType(); MethodInfo methodInfo = type.GetMethod("CreateFullName", BindingFlags.Instance | BindingFlags.NonPublic); // 引数付きメソッドの場合、第二引数は object[] 型の値を渡す Assert.AreEqual($"{FamilyName} {GivenName}", methodInfo.Invoke(human, null)); }インターフェイスメソッドのモック化
上記まではクラスメソッドについての記述となる。
テストのためだけにメソッドをオーバーライド可能な状態にするのに違和感を覚えていたが、 @naminodarie さんのコメントにより、インターフェイスを使えばオーバーライドを避けられると知った。
IHuman.cs/// <summary> /// テスト対象クラスのインターフェイス。 /// </summary> public interface IHuman { /// <summary> /// 年齢付きのフルネームを作成する。 /// </summary> /// <returns>年齢付きのフルネーム</returns> string CreateFullNameWithAge(); /// <summary> /// 年齢とその単位付きのフルネームを作成する。 /// </summary> /// <returns>年齢とその単位付きのフルネーム</returns> string CreateFullNameWithAge(string ageUnit); }Mock_Interface[Test] public void Mock_Interface() { var humanMock = new Mock<IHuman>(); // モック化の設定はクラスをもとにした場合と同様 humanMock.Setup(m => m.CreateFullNameWithAge(It.IsAny<string>())).Returns("上書き"); // Setup で指定した引数設定と同一のため、呼び出されず Returns で設定した戻り値が返却されている Assert.AreEqual("上書き", humanMock.Object.CreateFullNameWithAge("歳")); }仮実装や、特定環境でフェイクオブジェクトを使う際に特に効果を発揮しそうな予感。
その他メモ
ただし、下記の目的では使えない。
- メソッドから呼び出されるメソッドのモック化。
- protected なメソッドのモック化。
雑感
- 便利。
- 実際モック化で使うクラスは
Mock
なのか。- 静的メソッドを使用している処理まわりのテストはどのようにするのが適切だろう。静的メソッドの使用箇所は、メソッドの途中に記載するのではなく専用のメソッドを用意すべきだろうか。
- 投稿日:2020-08-30T22:53:37+09:00
KotlinとC#からみたJava言語
はじめに
「KotlinとC#からみたJava言語」というタイトルからは、KotlinやC#に詳しい人間が、Javaを体験してみた結果の報告というようなものを期待する人もいると思うのだが、これはそうした文章ではない。JavaもC#も遊び程度に触ったことがある人間が『Kotlin In Action』という本に刺激を受けて(また、業務都合でC#の本も一冊読んで)書いたものだ。この本は大変面白い本で、Kotlinの知識だけでなく、Java言語についての見識も深めることができた。
本稿はその知見を、プログラミング言語について考える材料になるのではないかと思い、残すものだ。
Javaの弱点
なぜKotlinやC#を勉強するとJavaの知見が得られるのか。それは両言語ともにJavaを大いに参考にし、それをもっと使いやすくするために設計された言語だからだ。そのため両言語が補った部分を見ると、逆にJavaの何を弱点として認識したのかが分かる。
では一体Javaの弱点とは何なのか。沢山あるのだが、ここでは、①値型定義機能の欠如、②仮想関数の継承、③NULL安全の欠如の3つに絞ることにする。この3つは互いに密接に関連している。
値型定義機能の欠如
値型とは、その実際の値によって表される型のことであり、値型の変数は直接その値を保持する。値型はJavaではbooleanやintやdoubleなどの基本データ型にしか存在しない。その逆は参照型であり、その型の変数は直接値を保持せず、その参照を値として持つ。Javaでは基本データ型以外の全ての型が参照型だ。
Javaでは基本データ型以外の型は、あらかじめ用意されている型もユーザが定義する型も、全て参照型となる。つまりユーザは値型を定義できない。
対照的にJavaを参考にして作られたC#には、初期バージョンの時から構造体というユーザが値型を定義するための言語機能が用意されていた。
値型は2つの値が同じであること(等価性という)の確認とそのコピーを作ることが容易である。例えば、Javaの基本型は等価演算子(==)で2つの値が等しいことを確認できるし、代入演算子(=)で値のコピーを作ることができる。
参照型の場合は、等価演算子ではその参照値が等しいこと、すなわち同一性(この場合は同じものを参照していること)の確認を行うことになるし、代入演算子ではその参照値のコピーを行い、実際の値のコピーは作らない。
Javaの参照型で等価性を確認するためは、equalsメソッドを(同時にhashCodeも)オーバーライドする必要があるし、オブジェクトのコピーが必要な場合は、cloneメソッドをオーバライドするか、コピーコンストラクタを用意する必要がある。
値型がないことが弱点となるのは、アプリケーションではこの2つの機能を使う機会がとても多いからだ。このため、Javaでアプリケーションを作る場合には、そのソースコード中には、大量のボイラープレート(言語仕様上必要となる、お決まりのコードのこと)が埋め込めれることとなる(実際には、Javaのソースコードにボイラープレートが大量に埋め込まれることになるのは、KotlinやC#でプロパティと呼ばれる言語機能がないことがより大きな要因だが、話が煩雑になるのでここでは省略する)。
ではなぜJavaは基本データ型以外の全ての型を参照型としたのか? 理由はJavaの作者のジェームス・ゴスリンに聞いてみるしかないのだが、想像してみることはできる。
Javaはオブジェクト指向言語として大いに喧伝された言語だ。Javaが生まれた当時、オブジェクト指向言語が備えているべき機能として、次の3つが挙げられていた。
- 情報隠蔽
- 継承
- 多態性
Javaはこのうち多態性を仮想関数の継承とオーバーライドにより実現するのだが、これはJavaでは参照型でしか実現できない機能だ。このため、オブジェクト指向言語を志向したJavaは、値型をできる限り排除しようとしたのではないだろうか。むしろ基本データ型を値型としたのはパフォーマンスなどを考慮しての止むを得ない選択だったと言えるだろう。
また、Javaは「演算子のオーバーロードができない」という特徴を持つが、これもユーザ定義の値型を作成できないという特徴と関連していると思われる。
演算子の多重定義についてJavaの作者のジェームス・ゴスリンは以下のようなを言っているそうだ。
おそらく、20~30%の人が演算子のオーバーロードを諸悪の根源と考えていることでしょう。どこかの誰かが演算子のオーバーロードを使って、たとえばリストの挿入に「+」なんかを割り当てたりして、人生をものすごくめちゃくちゃに混乱させてしまったものだから、これに大きなバツ印が付けられてしまったのでしょう。問題の多くは、分別のあるやり方でオーバーロードできる演算子はせいぜい半ダースくらいしかないというのに、定義したくなるような演算子は数千、数百万個もあり、その中からどれかを選ばなければならなくなるのですが、その選択自体が自分の直感に反してしまうというところからきています。
すなわち、ジェームス・ゴスリンは演算子のオーバーロードを嫌っている。
だが、もしJavaが言語機能としてユーザ定義の値型を採用していた場合、演算子のオーバーロードを採用しないという選択は出来なかったのではないだろうか。少なくとも言語の欠陥として目立ったはずだ。
例えば代表的な値型の一つに複素数型がある。これがもし定義できた場合、この型に対するプラス演算子やマイナス演算子が定義できないというのは言語的欠陥だろう。だがもし参照型としてしか定義できなければ、演算子のオーバーロードができなかったとしても、言語的欠陥として目立つことはなくなる。
値型を定義する機能をなくすことで大嫌いな演算子のオーバーロード機能を省くことが可能になる。これはJavaにとって、とても都合が良かったのではないだろうか。
仮想関数の継承
先述した通り、Javaはオブジェクト指向言語として大いに喧伝された言語であり、仮想関数の継承とオーバーライドによる多態性を積極的に支援するような言語設計が行われている。すなわち、特に何も指定しなければ、クラスは継承可能なクラスとなり、クラスで定義した関数は、オーバライド可能な仮想関数となる。
では、この言語仕様の何が問題なのだろうか。
実は、大いに問題がある。仮想関数は、「脆弱な基底クラス(fragile base class)」と呼ばれる、今ではよく知られている問題を引き起こす。このため、現在では使用を控えることが推奨されているのだ。
「脆弱な基底クラス問題」とは、基底クラスのコードが変更されたときに、その変更がサブクラスが期待するものではなくなってしまったために、サブクラスでの不正な挙動を引き起こすという問題だ。(詳しくは、https://en.wikipedia.org/wiki/Fragile_base_class などを参照していただきたい)
この問題を回避するために、Javaの名著として知られている『Effective Java』では、「継承のために設計および文書化する、でなければ継承を禁止する」方法を勧めている。すなわち、継承による多態性を使用する場合は、基底クラスの実装内容を文書として公開する(そうでなければそもそも継承は使わない)ように勧めている。これはオブジェクト指向の重要な要素と言われている「情報隠蔽」を放棄することを意味する。
NULL安全の欠如
基本型以外の型を全て参照型としたために目立ってしまった言語の欠陥がある。それがNULL安全の欠如だ。
Javaの参照型は値を参照する型ではあるが、何も参照していない値(NUL)を持つことができる。この時、通常の値を参照している前提で、その型のメンバ(メソッドやフィールド)を参照するとNullPointerException(いわゆるヌルポ)という例外が発生する。NULL安全とは簡単にいうとこのNullPointerExceptionを発生させない仕組みのことだ。
JavaがNULL安全ではないことが、言語的欠陥と言えるのは、一方でJavaが型安全をうたった言語であるからだ(例えば、C言語は型安全な言語とはいわれないし、NULL参照も問題とされない)。
型安全とは、型に対する不正な操作をコンパイルエラーの検出などで未然に防ぐ仕組みのことだ。例えば、ある変数の型がString型である場合は、その変数にはString型に許された操作以外の操作を行おうとすると、Javaではコンパイルエラーが発生する。NULL参照は、Javaの型安全における唯一の例外となっている。
JavaにおいてNULL安全の欠如が問題となったのは、Javaが大変広く普及した言語であり多くのプログラマがこの問題に悩まされたことが大きいが、基本データ型以外は全て参照型というJavaの特徴もこれを後押ししている。
Javaの名誉のために一言付け加えておくと、Javaが生まれた当時、NULL安全という言葉は無かったように思う。少なくとも一般的に知られた機能ではなかった。このためJavaがNULL安全ではないというのは仕方がないことと言える。
弱点への対策
では、これらの弱点をJavaはどのように克服しようとしているか。また、KotlinとC#はこれらに対する対策をどのように行っているかをみていこう。
値型定義機能の欠如への対策
Javaの場合
Javaではユーザ定義型は全て参照型となるため、厳密には値型を定義することはできないが、値のように振舞う型を定義することはできる。すなわち、equalsメソッドで同値比較が可能で、コピーを作るのが容易な型というだ。但し、この場合は多くのボイラープレートをソースに追加しなければならなくなることは、先に述べた通りとなる。
この問題に対する解決策として、まず世に出てきたのがIDEによるボイラープレートの自動生成という方法だ。しかし、これはソースを作成する時の負担軽減にはなっても、あとで見直す時の負担の軽減にはならない。
そこで出てきたのがLombokというライブラリだ。このライブラリを導入するとクラスにDataアノテーションをつけるだけで、ソースコードからは見えないところで、equalsなどのボイラープレートを自動生成してくれる。
さらに、Java14からはRecordという新しい仕組みが導入され、値型がないというJavaの弱点がさらに補完されることになった。
Kotlinの場合
KotlinはJavaと同様、JVM上で動くように設計された言語だ。このため、基本データ型以外の型は全て参照型であるという性質を持ち、値型は定義できない。但し、Javaと同じで値型のように振舞うクラスを定義することはできる。
クラスにdataという修飾子をつけるだけで、equals、hashCode、toString、copyなどのメソッド自動生成される仕組みが最初から用意されている(これらのメソッドはソースからは見えない)。
C#の場合
先に述べた通り、C#には構造体という値型を定義するための仕組みを持っている。
さらに、匿名型というリテラルのように使える型も、同値比較のためのEquals関数が自動で付与されるため、値型のように使用することができる。
C#7.0からはタプルという型が使えるようになった。これも値型のように同値比較やコピーが容易な型だ。
仮想関数の継承への対策
Javaの場合
final修飾子をつけたクラスを継承しようとしたり、同修飾子をつけたメソッドをオーバライドしようとするとコンパイルエラーとなる。これはJavaに最初からついていた機能だ。基本的にクラスやメソッドには全てfinalをつけるようにすることで、「脆弱な基底クラス」問題を回避することができる。
Java5.0で導入されたアノテーションにより、メソッドをオーバライドする際にはOverrideアノテーションをつけることが推奨されるようになった。これはオーバライド行わない新規メソッドを意図せず作成してしまうというミスを回避するために追加された機能だが、オーバライドを行うことを少しだけ面倒にするという効果がある。
先に紹介した『Effective Java』では「継承よりコンポジションを選ぶ」という方法を勧めている。これは、既存のクラスを継承する代わりに、既存のクラスのインスタンスを参照するprivateフィールドを新たなクラスに持たせ、新たなクラスの各メソッドは、保持している既存クラスの対応するメソッドを呼び出してその結果を返す(これを委譲と呼ぶ)という方法だ。これにより「脆弱な基底クラス」問題を回避することができる。また、大抵のIDEはこの方法を支援する機能を持っている。但し、この方法は大量のボイラープレートを生み出す。
Kotlinの場合
Kotlinでは、デフォルトでクラスをfinalとして扱う。つまり、何も修飾子をつけないクラスは派生クラスを作ることができない。継承を行えるようにするためにはopen修飾子をつけなければならない。
同じようデフォルトでメソッドをfinalとして扱う。つまり、何も修飾子を付けないメソッドはオーバーライド不可となる。オーバライド可能とするためにはopen修飾子を付けなければならない。また、派生クラスでそのメソッドをオーバライドする際にはoverride修飾子を付けなければならない(Javaで対応する機能のOverrideアノテーションは任意)。
Kotlinは「継承よりコンポジションを選ぶ」という方法を積極的に支援するを持っている。byキーワードによってインターフェースから継承されるメソッドを他のクラスに委譲することが簡単にできるようになっている。
Kotlinには、継承によらないクラスの機能拡張の方法として、拡張関数という仕組みが用意された。これは継承の代わりとはならないし、普通のメソッド定義よりも制限が多いが、既存のクラスにメソッドを後から追加する強力な手段となっている。
C#の場合
C#では、クラスにsealed修飾子をつけることでそのクラスの継承を禁止することができる。但し、Kotlinとは違ってこれはデフォルトではない。
Kotlinと同じようにメソッドはデフォルトでオーバライド可能とはならない。オーバーライド可能とするためには、virtual修飾子を付けねばならず、派生クラスでそのメソッドをオーバライドする際には、override修飾子を付けなければならない。
Kotlinのようにクラス委譲の仕組みは用意されていない。クラス委譲を行う際には、Javaと同じようにボイラープレートを書かなければならない。
Kotlinと同じように、既存クラスにメソッドを追加する方法として拡張メソッドという仕組みがある。もっともこの機能は、C#3.0で追加されたものなので、Kotlinがそれを参考に拡張関数という仕組みを言語の仕様に取り込んだということだ。
NULL安全の欠如への対策
Javaの場合
JSR305や先に紹介したLombokなどのライブラリを導入すると、NullableアノテーションやNonNullアノテーションが使えるようになる。これらはNullPointerExceptionの検出を助けてくれる(その仕様はライブラリごとに異なる)。
Java8からは、Nullの可能性がある参照型のラッパークラスとしてOptionalクラスが使えるようになった。このクラスのメソッドはNullとそれ以外の場合とを区別するため、NullPointerExceptionの発生を減らすことができる。
Kotlinの場合
Kotlinの型システムではnull許容型(nullの可能性がある型)とnull非許容型(nullを許容しない型)が区別されているため、NullPointerExceitionの回避が容易になっている。Kotlinで普通に型名をそのまま使って型を(例えばStringのように)宣言した場合、それはnull非許容型となる。null許容型とするためには型名の後に疑問符を(例えばString?のように)付ける必要がある。
null非許容型は、nullを持たないため、NullPointerExceitionを発生させることはない。さらにKotlinは、null許容型に対して次のような構文を用意しNullPointerExceitionを発生しにくくしている。
- 安全呼び出し演算子(?.)
- nullチェックとメソッド呼び出しを結合する演算子
- エルビス演算子(?:)
- nullの代わりにデフォルト値を返す演算子
- 安全キャスト(as)
- 指定された型に値をキャストしようとし、型が違う場合はnullを返す
- 非null表明(!!)
- 任意の型をnull非許容型に変換するための構文。値がnullの場合は例外をスローする
- let関数
- nullチェックとラムダ呼び出しを結合するための構文
C#の場合
2019年9月にリリースされたC# 8.0からはnull許容参照型が使えるようになった。これはKotlinと同じように、型名の後ろに疑問符を付けたときのみ、その変数や関数の戻り値にnullを許可するという機能だ。Kotlinとの違いは、過去のバージョンとの互換性を保つために、null 許容注釈コンテキストを有効にしたときのみ、null許容参照型が使えることだ。
C#にはそれぞれ導入時期は異なるが、NULLに配慮した次のような演算子がある。
- null合体演算子(??)
- 左側のオペランドがnullではない場合、そのオペランドの値を、それ以外の場合は、右側のオペランドの評価結果を返す演算子
- null合体割り当て演算子(=??)
- 左側のオペランドがnullに評価された場合にのみ、右側のオペランドの値を左側のオペランドに割り当てる演算子
- null免除演算子(!)
- オペランドをnull非許容型として解釈するよう指示を与える演算子。コンパイラの静的フロー分析にのみ影響を与え、実行時には影響を与えない
- null条件演算子(?.、?[])
- 左側のオペランドがnull参照ののときにはnullを、それ以外の場合は左側のオペランドのメンバ(フィールドやプロパティやメソッドなどのこと)である右側のオペランドを評価し、その結果を返す。インデクサーを呼び出すときのみ?[]を使用する
最後に
C++言語の設計者であるストラウストラップはその著書『C++の設計と進化』で次のように述べている。
「私が予言したように、Javaは年月を経て新しい機能を身につけていき、その結果単純さという"もともとの長所"を打ち消してしまったが、だからといって性能をあげることもなかった。新しい言語というものはいつだって「単純さ」を売りにし、その後、実世界のアプリケーション向けにサイズも複雑さも増して、生き延びていく」と述べている。
全くその通りだなと思う。JavaやC#は今ではもう十分複雑な言語だし、まだ生まれて間もないKotlinもやがて複雑な言語に育っていくのだろう。生きている言語とはそういうものなのだ。
- 投稿日:2020-08-30T16:45:38+09:00
Fungusをなんとなくそれっぽく使う
準備するよ!
①AssetStoreから「Fungus」を探してね!
どこかわかんにゃい
→一番上の「Windows」タブの中にあるよ〜!
このきのこのやつね
②何も考えずに全てをインポート
まあまあ時間かかるから茶でもしばいて
文字読むのめんどい人はこっち見て。めっちゃわかりやすいから。
https://youtu.be/evENw4lCyE4使ってみるよ!
・勝手にfungusとかのフォルダ追加されてるからとりま消さないようにする
・「Fungus」→「Resources」→「Prefabs」のフォルダにいく
・「SayDialog」をヒエラルキーにペコっと貼り付けて!(でも消さないでおいてね)
・貼り付けたやつをUnpack Prefabして、自分の作ってるPrefabフォルダにいれる
・「SayDialog」のパネルだのテキストだのイメージだの自分仕様にする・上のバーって一覧あるタブの「Tools」→「Fungus」→「Create」→「Flowchart」
これ出したらSceneViewみたいにずっと表示するように置いておくと便利?・FlowchartのNewBlock押すとこんなの出てくる
・Block Nameは自分がわかる名前でおk
・Execute On EventでFungus(会話ウィンドウ)を呼びたいとこを設定Execute On Event ってどれ使えば良いの?
・Game Started
ゲーム開始時にすぐに呼びたい時に使う・Scene/Message Received
自分でタイミング決めたい人用。スクリプトとかから呼ぶやつ・UI/Button Clicked
ボタンを押して呼びたい時に使う他にも色々種類あるからもっと調べれば良い感じに綺麗にできるんだろうけど
まあ大体この3つでどうにかなると思ってる。
やだ!やめて!怒らないで!FlowchartのNew Blockがあるところで右クリック→Add Blockで新規作成できる
呼びたいタイミングが違うやつとか、ウィンドウごとにじゃんじゃか作ろうね〜実際に会話を作ってみる
さっきのExecute On Eventがあった場所(Blockを押すと出てくるヒエラルキー)
の下の方に「↑」とか「↓」とか「+」とかあるっしょ?「+」押してみ。
げろぉ……なんかめっちゃある。けどここで会話を作ります。Narrative/Say
・普通の会話。タ特に何も考えずにセリフを入れるだけのお仕事。基本はこれ使う。
ちなみにこれ系は全てコピペできるから同じやつをわざわざ探さなくても良いよ。便利!
・Story Textに発言させたいテキストを入れる
・Show Alwaysにチェック入れてると毎回呼ばれる。外すと1回だけしか表示されない
・Wait For Clickのチェックを外すと自動で次にいく。多分。確認してないけどきっとそう…
あとはそんないじらんでも平気かな。Variableってところで条件を入力するんだけど、さっきのFlowchartの下らへんに
「Variables」って白いのが隠れてると思うからクリックして表示。
「+」を押すとBooleanとかIntegerとか色々あるので作りたい条件を設定。
・チェックを入れたらTrue、なきゃFalse。簡単だね!
ここで条件を作っておけばさっきのifブロックで選択できるようになるよ!
ifとかの条件定義→Sayコマンドなど→Endコマンドで挟むように置いてね。
置いたコマンドの左側にある≡で上下の順番を変えられるよ!Endは必須!Variable/Set Variable
・会話の中で条件を変更するやつ
変更したいタイミングで入れて、=でTrue、!=でFalseにする。
この辺は変更してみたら表示されるから目で見て設定できると思う〜
ここまでで他の会話でboolでTrueにして条件で会話内容を変えたり、
話しかける度に内容が変わるとか出来るようになりました!
これだけでもゲームが作れるね選択したりとかObject動かしたりとかしたい
・選択肢
①選択肢の数だけFlowchartでAdd Blockする
選択した後に実行したいことを各ブロックに設定する。
セリフを出す〜とか、BoolをTrueにする〜とか、後でやるけど画面遷移する〜とか②選択肢の数だけNarrative/Menuを追加
さっきの画像でいうNew Blockのところ。
選択肢を出したいBlockに選択肢分のMenuを追加!
・Text:選択肢に表示するテキスト。「はい」とか、「そんなことはできない!」とか
・Target Block:選んだ時に移動する先のBlockを設定
これをすることでFlowchart上で分岐が作られます〜
おわかりいただけただろうか…
もうこれでFungusマスターを名乗っても良いレベルです。
・その他Scriptを利用してFungusを制御していく系とか
Messageで制御
①Execute On EventをMessage Receivedに変更
②その下のMessageって書いてあるところに受け取るMessageを設定(何でもいいよ)これだけで、他の会話ウィンドウとかからSend Messageをしたら呼び出すという動きができる
FungusからScriptを制御
Scripting/Call Method
・使いたいScriptがついてるObjectをTarget Objectにペコっと貼り付ける
・使いたいScriptの名前を書く:()は付けなくていいよ
・Delayで何秒後に実行されるか決めるこれでFungusからScriptを呼ぶことに成功!やったね!
ScriptからFungusを制御
using Fungus;
を必ず付けてね、必ずだよ!!
Flowchart.BroadcastFungusMessage(fuga);
Fungusを呼びたい箇所でこのコードを書く。
fugaの部分にMessageで制御の時にも使ったMessage名を書く。
これでScriptからMessageを送信し、Fungusが呼び出せる!やったねじょにちゃん、Fungusが使えるよ
- 投稿日:2020-08-30T15:51:55+09:00
Google Cloud Text-to-Speech .NET用クイックスタートをUnityで実行する方法
やりたいこと
Google Cloud Text-to-SpeechにはUnity用のSDKが用意されていません。
そこで、.NET用のクイックスタートをUnityで実行します。
ソースコードは以下にアップロードしてあります。
https://github.com/AzetaTakuya/GoogleCloudText-to-SpeechForUnity結果
分かりづらいですが、sample.mp3を保存することができました。
sample.mp3を再生するとHello, World!と再生されます。
実装
環境
- Windows10 Home
- Unity 2019.4.8f1
- Visual Studio 2017 (Community)
Google.Cloud.TextToSpeech.V1 -Version 2.0.0- Google.Cloud.TextToSpeech.V1 -Version 1.0.0
※2020/08/30現在 ver2.0.0を使用すると2度目の実行からフリーズしてしまったのでver1.0.0を使用します。原因/対策が分かる方いましたら教えてください。手順
- 認証ファイルの作成
- .NET用ライブラリ(Nuget Package)をインストール
- ライブラリをUnityにインポート
- QuickStartをUnity用に修正
- 実行
※手順は基本的に公式ドキュメント:クイックスタートに従います。①認証ファイルの作成
公式ドキュメント:クイックスタートの①~④までを完了すると、JSONファイルが生成されます。
今回、環境変数の設定はスクリプトから行うので省略して構いません。②.NET用ライブラリ(Nuget Package)をインストール
NugetPackageをUnityにインストールする方法として、NuGetForUnityが有名ですが宗教上の理由で使用しません。
VisualStudioのパッケージマネージャーコンソールからインストールします。VisualStudioプロジェクト作成
VisualStudioを開いて、【ファイル -> 新規作成 -> プロジェクト】からコンソールアプリ(.NET Framework)を作成します。
今回は、プロジェクト名は[TextToSpeechV1]とし、.NET Framework 4.7.1を使用しました。
プロジェクトの作成ができたら、【ツール -> Nuget パッケージマネージャー -> パッケージマネージャーコンソール】から、パッケージマネージャーコンソールを開きます。
パッケージマネージャーコンソールが開いたら以下を実行します。
PM> Install-Package Google.Cloud.TextToSpeech.V1 -Version 1.0.0実行が終了したら、【TextToSpeechV1(※作成したプロジェクト)/Packages】を確認します。
③ライブラリをUnityにインポート
先ほどのフォルダの中にはDLLが入っているので、Unityにインポートできる様に修正します。
作業内容としては、
① Grpc.Core.1.22.0以外のフォルダ内にある【lib/net45/】内のファイルを全てpacakes直下に移動し、Grpc.Core.1.22.0以外のフォルダを全て削除
② 【Grpc.Core.1.22.0/lib/netstandard2.0】と【Grpc.Core.1.22.0/lib/netstandard1.5】を削除
③ 【Grpc.Core.1.22.0/runtimes/win/native】内のgrpc_csharp_ext.x64.dllかgrpc_csharp_ext.x84.dllのどちらかをgrpc_csharp_ext.dllに名前を変更以上が完了したら、Unityプロジェクトを作成します。
Unityプロジェクトを作成したら、先程修正したpakegesフォルダの名前をPluginsに変更し、Unityにインポートします。
これでUnityにライブラリのインポートが完了しました。④QuickStartをUnity用に修正
.NET用のクイックスタートにあるスクリプトをUnity用に修正・環境変数の追加をしたものが以下となります。
using System.IO; using UnityEngine; using System; using Google.Cloud.TextToSpeech.V1; public class QuickStart : MonoBehaviour { public string credentialsPath; public string saveFile; void Start() { #region Environment Variable if (!File.Exists(credentialsPath)) { Debug.LogError("failure" + credentialsPath); return; } else { Debug.Log("success: " + credentialsPath); } Environment.SetEnvironmentVariable("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath); #endregion #region QuickStart // Instantiate a client TextToSpeechClient client = TextToSpeechClient.Create(); // Set the text input to be synthesized. SynthesisInput input = new SynthesisInput { Text = "Hello, World!" }; // Build the voice request, select the language code ("en-US"), // and the SSML voice gender ("neutral"). VoiceSelectionParams voice = new VoiceSelectionParams { LanguageCode = "en-US", SsmlGender = SsmlVoiceGender.Neutral }; // Select the type of audio file you want returned. AudioConfig config = new AudioConfig { AudioEncoding = AudioEncoding.Mp3 }; // Perform the Text-to-Speech request, passing the text input // with the selected voice parameters and audio file type var response = client.SynthesizeSpeech(new SynthesizeSpeechRequest { Input = input, Voice = voice, AudioConfig = config }); // Write the binary AudioContent of the response to an MP3 file. using (Stream output = File.Create(saveFile)) { response.AudioContent.WriteTo(output); Debug.Log($"Audio content written to file " + saveFile); } #endregion } }⑤実行
credentialsPathに認証ファイルのパスを、saveFileに保存ファイルパス(.mp3)を入れ実行すると音声ファイルが保存されます。
まとめ
上手くできないという声が多い気がしたので書いてみました。
GoogleCloutPlatform自体の使い方については結構雑な感じなので要望があれば書こうと思います。
なんでGoogle.Cloud.TextToSpeech.V1 -Version 2.0.0では安定動作しないんだ...?
気が向いたらWindows以外も対応します。参考
なし
- 投稿日:2020-08-30T15:07:39+09:00
画像のエッジ抽出(ラプラシアンフィルタ)
境界(エッジ)抽出例
ディジタル画像処理の基礎と応用
p.45
ラプラシアンフィルタLaplacian1.cs//c:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe Laplacian1.cs using System.Drawing; using System.Drawing.Imaging; using System; public class Laplacian1 { public static void Main(string[] args) { if(args.Length==2){ string path1;//in file name string path2;//out file name path1 = @".\" + args[0]; path2 = @".\" + args[1]; Bitmap image1; image1 = new Bitmap(path1, true); int i,j,nx,ny; int gray; Color col; //int[,] f = new int[256,256];//← 書籍のソースは誤り nx = (int)image1.Width; ny = (int)image1.Height; int[,] f = new int[nx,ny]; Bitmap bmp = new Bitmap(image1); for(j=0;j<ny;j++){ for(i=0;i<nx;i++){ col = bmp.GetPixel(i,j); f[i,j] = (int)((col.R+col.G+col.B)/3); } } Console.WriteLine(" _1 "); for(j=1;j<ny-1;j++){ for(i=1;i<nx-1;i++){ gray=f[i,j-1] + f[i-1,j] - 4*f[i,j] + f[i+1,j] + f[i,j+1]; gray += 128; if(gray>158){ //閾値は、適当に gray=0; }else{ gray=255; } bmp.SetPixel(i,j,Color.FromArgb(gray,gray,gray)); } } Console.WriteLine(" _2 "); bmp.Save(path2, System.Drawing.Imaging.ImageFormat.Png); Console.WriteLine(" _3 "); return; } } }
- 投稿日:2020-08-30T11:56:00+09:00
RestSharpを使ってDigest認証を試してみた
背景
Windowsアプリ開発の中でHTTPクライアント周りを調べていたのですが、標準では
HttpClient
クラスが用意されています。
HttpClient クラス (System.Net.Http) | Microsoft Docsが、このクラスが結構曲者らしいのです。(実際使ったことがないのでわかりませんが)
Qiitaで調べてみても結構出てきて、これは使いたくないな、、と正直思いました。HttpClientをusingで囲わないでください - Qiita
.NET(Framework)のHttpClientの取り扱いには要注意という話 - Qiitaさらに調べたところ、RestSharpというREST APIクライアントライブラリがあることを知り、割と良さそうだと思ったので、Digest認証で試してみることにしました。
導入方法
NuGetパッケージを追加する必要があります。IDEまたはコマンドラインを使用して実行できます。
NuGet Gallery | RestSharp 106.11.4また、Digest認証をやる場合は以下も追加します。
NuGet Gallery | RestSharp.Authenticators.Digest 1.0.2使い方
例えばAPIがGETメソッドで、レスポンスとして以下のJSONデータを返してくるものとします。
{"foo": "bar", "baz": {"foo": "bar", "baz": "qux"}}まず、JSONデータを受け取るためのデータクラスを作成します。
Foo.cspublic class Foo { public string foo { get; set; } public Baz baz { get; set; } }Baz.cspublic class Baz { public string foo { get; set; } public string baz { get; set; } }そして、以下のようにリクエスト&レスポンスからJSONデータをデシリアライズします。
// クライアントの生成(BaseURLを設定しておく) var client = new RestClient("https://hogehoge.api/1.1) { // Digest認証の設定 Authenticator = new DigestAuthenticator("username", "password"); }; // JSONシリアライザーの設定(今回はUtf8Jsonを使用) client.UseUtf8Json(); // リクエストの生成(Resourceとレスポンスのデータフォーマットを設定) var request = new RestRequest("foobaz", DataFormat.Json); // 同期呼び出し var response = client.Get(request); // JSONデータをデシリアライズ var result = new Utf8JsonSerializer().Deserialize<Foo>(response);大まかには
RestClient
クラスのインスタンスを生成し、認証方式やJSONシリアライザーの設定をして、RestRequest
クラスのインスタンスをclient.Get
メソッドの引数に渡すことでできます。
そして、変数response
に結果が入っているので、あとはJSONデータをデシリアライズします。また、以下のように非同期呼び出しにして、デシリアライズをまとめることもできます。
// 非同期呼び出し(resultがFoo型として返ってくる) var result = await client.GetAsync<Foo>(request);参考URL
- 投稿日:2020-08-30T08:53:02+09:00
都道府県ドロップダウン作成
コントローラー
コントローラー側の設定は以下の通り
controller.cs[HttpGet] public ActionResult Index() { int accountId = getAccountIdFromCookie(); ProfileModel profileList = getProfileList(accountId); ProfileViewModel vm = new ProfileViewModel(); vm.Name = profileList.Name; vm.Email = profileList.Email; if(!(profileList.Prefucture == null)) { string pnumber = profileList.Prefucture; var selectoptions = getSelectListItem(); vm.Prefucture = selectoptions.Where(p => p.Value == pnumber).First().Text; } vm.Address = profileList.Address; return View("index", vm); } public ActionResult Edit(string name, string email, string prefucture, string address) { ProfileViewModel vm = new ProfileViewModel(); vm.Name = name; vm.Email = email; vm.Prefucture = prefucture; vm.Address = address; ViewBag.SelectOptions = getSelectListItem(); return View("edit", vm); } private List<SelectListItem> getSelectListItem() { List<SelectListItem> selectoptions = new List<SelectListItem>(); var group1 = new SelectListGroup() { Name = "北海道・東北" }; var group2 = new SelectListGroup() { Name = "関東" }; var group3 = new SelectListGroup() { Name = "甲信越・北陸" }; var group4 = new SelectListGroup() { Name = "東海" }; var group5 = new SelectListGroup() { Name = "関西" }; var group6 = new SelectListGroup() { Name = "中国・四国" }; var group7 = new SelectListGroup() { Name = "九州・沖縄" }; selectoptions.Add(new SelectListItem() { Value = "1", Text = "北海道", Group = group1 }); selectoptions.Add(new SelectListItem() { Value = "2", Text = "青森県", Group = group1 }); selectoptions.Add(new SelectListItem() { Value = "3", Text = "岩手県", Group = group1 }); selectoptions.Add(new SelectListItem() { Value = "4", Text = "宮城県", Group = group1 }); selectoptions.Add(new SelectListItem() { Value = "5", Text = "秋田県", Group = group1 }); selectoptions.Add(new SelectListItem() { Value = "6", Text = "山形県", Group = group1 }); selectoptions.Add(new SelectListItem() { Value = "7", Text = "福島県", Group = group1 }); selectoptions.Add(new SelectListItem() { Value = "8", Text = "茨城県", Group = group2 }); selectoptions.Add(new SelectListItem() { Value = "9", Text = "栃木県", Group = group2 }); selectoptions.Add(new SelectListItem() { Value = "10", Text = "群馬県", Group = group2 }); selectoptions.Add(new SelectListItem() { Value = "11", Text = "埼玉県", Group = group2 }); selectoptions.Add(new SelectListItem() { Value = "12", Text = "千葉県", Group = group2 }); selectoptions.Add(new SelectListItem() { Value = "13", Text = "東京都", Group = group2 }); selectoptions.Add(new SelectListItem() { Value = "14", Text = "神奈川県", Group = group2 }); selectoptions.Add(new SelectListItem() { Value = "15", Text = "新潟県", Group = group3 }); selectoptions.Add(new SelectListItem() { Value = "16", Text = "富山県", Group = group3 }); selectoptions.Add(new SelectListItem() { Value = "17", Text = "石川県", Group = group3 }); selectoptions.Add(new SelectListItem() { Value = "18", Text = "福井県", Group = group3 }); selectoptions.Add(new SelectListItem() { Value = "19", Text = "山梨県", Group = group3 }); selectoptions.Add(new SelectListItem() { Value = "20", Text = "長野県", Group = group3 }); selectoptions.Add(new SelectListItem() { Value = "21", Text = "岐阜県", Group = group4 }); selectoptions.Add(new SelectListItem() { Value = "22", Text = "静岡県", Group = group4 }); selectoptions.Add(new SelectListItem() { Value = "23", Text = "愛知県", Group = group4 }); selectoptions.Add(new SelectListItem() { Value = "24", Text = "三重県", Group = group4 }); selectoptions.Add(new SelectListItem() { Value = "25", Text = "滋賀県", Group = group5 }); selectoptions.Add(new SelectListItem() { Value = "26", Text = "京都府", Group = group5 }); selectoptions.Add(new SelectListItem() { Value = "27", Text = "大阪府", Group = group5 }); selectoptions.Add(new SelectListItem() { Value = "28", Text = "兵庫県", Group = group5 }); selectoptions.Add(new SelectListItem() { Value = "29", Text = "奈良県", Group = group5 }); selectoptions.Add(new SelectListItem() { Value = "30", Text = "和歌山県", Group = group5 }); selectoptions.Add(new SelectListItem() { Value = "31", Text = "鳥取県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "32", Text = "島根県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "33", Text = "岡山県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "34", Text = "広島県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "35", Text = "山口県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "36", Text = "徳島県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "37", Text = "香川県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "38", Text = "愛媛県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "39", Text = "高知県", Group = group6 }); selectoptions.Add(new SelectListItem() { Value = "40", Text = "福岡県", Group = group7 }); selectoptions.Add(new SelectListItem() { Value = "41", Text = "佐賀県", Group = group7 }); selectoptions.Add(new SelectListItem() { Value = "42", Text = "長崎県", Group = group7 }); selectoptions.Add(new SelectListItem() { Value = "43", Text = "熊本県", Group = group7 }); selectoptions.Add(new SelectListItem() { Value = "44", Text = "大分県", Group = group7 }); selectoptions.Add(new SelectListItem() { Value = "45", Text = "宮崎県", Group = group7 }); selectoptions.Add(new SelectListItem() { Value = "46", Text = "鹿児島県", Group = group7 }); selectoptions.Add(new SelectListItem() { Value = "47", Text = "沖縄県", Group = group7 }); return selectoptions; }ビュー
ビュー側の設定は以下の通り
Edit.cshtml@model TestApp8.Models.ProfileViewModel @{ ViewBag.Title = "Profile"; }<div class="login-form"> <form method="post" action="~/profile/edit"> <h1>@ViewBag.Title</h1> <div class="form-item"> @Html.TextBoxFor(model => model.Name, new { type = "name", @value = Model.Name }) </div> <div class="form-item"> @Html.TextBoxFor(model => model.Password, new { type = "password", @value = Model.Password }) </div> <div class="form-item"> @Html.DropDownListFor(model => model.Prefucture, (IEnumerable<SelectListItem>)ViewBag.SelectOptions, " 都道府県 ", new { @class = "title_list" } ) <br /> </div> <div class="form-item"> @Html.TextBoxFor(model => model.Address, new { type = "text", @value = Model.Address }) </div> <div class="button-panel"> <button type="submit" class="button" title="Sign In" value="Sign In">Update</button> </div> <label style="color:#ff0000">@Html.ValidationSummary()</label> </form> </div>
- 投稿日:2020-08-30T00:10:05+09:00
AvalonEdit の改行マークを変更したい
はじめに
今回は小ネタです。この記事は AvalonEdit のカスタマイズを試みた際の備忘録です。
自作のメモ帳アプリ で、エディタコントロールとして非常に高機能なライブラリである AvalonEdit を使用しています。標準で多様な動作を実現できますが、機能を拡張しようとすると詰まりがちです。巨大なライブラリ故に内部の実装が複雑で、私の理解力では解読が難航します。
時間が経つと経緯を忘れそうなのでメモとして残しています。やりたいこと
AvalonEdit は改行文字を可視化することができ、標準では "\r", "\n", "¶" の記号で表示されます。これをサクラエディタのような矢印("←", "↓", "↵")に変更したいです。
株式会社かなざわネット様のサイト でこれを叶える方法を解説頂いていますが、AvalonEdit のソースに手を加えずに実現する方法を模索しました。
環境
- Visual Studio Community 2019
- .NET Core 3.1 (C#/WPF)
- Avalon Edit 6.0.1
結論
以下でたぶんできてます。
WrappedTextViewusing ICSharpCode.AvalonEdit.Rendering; using System.Collections.Generic; using System.Reflection; using System.Windows; using System.Windows.Media; using System.Windows.Media.TextFormatting; namespace TestApp { public class WrappedTextView : ICSharpCode.AvalonEdit.Rendering.TextView { private const string CR_CHAR = "\u2190"; private const string LF_CHAR = "\u2193"; private const string CRLF_CHAR = "\u21B5"; protected override Size MeasureOverride(Size availableSize) { if (this.Options?.ShowEndOfLine == true) this.RefreshNonPrintableCharacterTexts(); return base.MeasureOverride(availableSize); } private void RefreshNonPrintableCharacterTexts() { var globalProterties = (TextRunProperties)typeof(ICSharpCode.AvalonEdit.Rendering.TextView) .GetMethod("CreateGlobalTextRunProperties", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.InvokeMethod) .Invoke(this, null); var formatter = (TextFormatter)typeof(ICSharpCode.AvalonEdit.Rendering.TextView).Assembly .GetType("ICSharpCode.AvalonEdit.Utils.TextFormatterFactory") .GetMethod("Create", BindingFlags.Static | BindingFlags.Public | BindingFlags.InvokeMethod) .Invoke(null, new[] { this }); var cachedElements = typeof(ICSharpCode.AvalonEdit.Rendering.TextView) .GetField("cachedElements", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(this); var nonPrintableCharacterTexts = (Dictionary<string, TextLine>)cachedElements.GetType() .GetField("nonPrintableCharacterTexts", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(cachedElements); var elementProperties = new VisualLineElementTextRunProperties(globalProterties); elementProperties.SetForegroundBrush(this.NonPrintableCharacterBrush); var cr = FormattedTextElement.PrepareText(formatter, CR_CHAR, elementProperties); var lf = FormattedTextElement.PrepareText(formatter, LF_CHAR, elementProperties); var crlf = FormattedTextElement.PrepareText(formatter, CRLF_CHAR, elementProperties); nonPrintableCharacterTexts ??= new Dictionary<string, TextLine>(); nonPrintableCharacterTexts["\\r"] = cr; nonPrintableCharacterTexts["\\n"] = lf; nonPrintableCharacterTexts["¶"] = crlf; cachedElements.GetType() .GetField("nonPrintableCharacterTexts", BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(cachedElements, nonPrintableCharacterTexts); } } }詳細
改行マークは下記の VisualLineTextSource.CreateTextRunForNewLine() で指定されています。
これを取り巻く要素を調整して、本件の実現を目指しました。VisualLineTextSourcenamespace ICSharpCode.AvalonEdit.Rendering { sealed class VisualLineTextSource : TextSource, ITextRunConstructionContext { TextRun CreateTextRunForNewLine() { string newlineText = ""; DocumentLine lastDocumentLine = VisualLine.LastDocumentLine; if (lastDocumentLine.DelimiterLength == 2) { newlineText = "¶"; } else if (lastDocumentLine.DelimiterLength == 1) { char newlineChar = Document.GetCharAt(lastDocumentLine.Offset + lastDocumentLine.Length); if (newlineChar == '\r') newlineText = "\\r"; else if (newlineChar == '\n') newlineText = "\\n"; else newlineText = "?"; } return new FormattedTextRun(new FormattedTextElement(TextView.cachedElements.GetTextForNonPrintableCharacter(newlineText, this), 0), GlobalTextRunProperties); } } }失敗1:VisualLineTextSource を置き換える
CreateTextRunForNewLine() は非公開メソッドであり、VisualLineTextSource は sealed クラスのため継承できません。
したがって、CreateTextRunForNewLine() を再定義した新しいクラスを作り、VisualLineTextSource の呼び出し元を新クラスに置き換えることを考えました。ところが VisualLineTextSource は以下のような呼び出し階層を持ちます。
まず非公開メソッドが入るためこれは変更できません。その先は多くの参照元があるメソッドに繋がるため、影響範囲が大きく、現実的ではありません。VisualLineTextSource
└ private TextView.BuildVisualLine()
├ public TextView.GetOrConstructVisualLine()
│ └ 多数の呼び出し元
└ private TextView.CreateAndMeasureVisualLines()
└ protected TextView.MeasureOverride()
└ 多数の呼び出し元失敗2:TextViewCachedElements を置き換える
CreateTextRunForNewLine() 内では、改行マークを TextViewCachedElements.GetTextForNonPrintableCharacter() で TextLine クラスに変換して呼び出し元に返しています。
GetTextForNonPrintableCharacter() の書き換えを検討しましたが、これも失敗です。TextViewCachedElements も継承できないため、やるとすればクラスごと置き換えになりますが、
呼び出し元はいずれも非公開メソッドの TextView.OnDocumentChanged(), TextView.RecreateCachedElements() であるため、これも現実的な対応を見出せません。可能性:TextViewCachedElements.nonPrintableCharacterTexts を無理やり調整する
失敗2の TextViewCachedElements は GetTextForNonPrintableCharacter() で改行マークを受け取り、TextLine に変換します。この TextLine は、ゆくゆくは VisualLineElement に設定され、画面描画に使用されます。
一度変換した「改行マーク」と「TextLine」の組み合わせは nonPrintableCharacterTexts にキャッシュされ、次回からはこれが再利用されています。つまり、このキャッシュを調整し {"¶", TextLine("↵") } のような組み合わせを登録すれば、描画されるマークをコンバートできるということです。TextViewCachedElements のインスタンスは TextView.cachedElements という internal なメンバ変数のため、TextView を継承してもアクセスできません。TextViewCachedElements.nonPrintableCharacterTexts も同様に非公開です。
ここではリフレクションを使いメンバ変数にアクセスして書き換えることになります。var cachedElements = typeof(ICSharpCode.AvalonEdit.Rendering.TextView) .GetField("cachedElements", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(this); // this は TextView var nonPrintableCharacterTexts = (Dictionary<string, TextLine>)cachedElements.GetType() .GetField("nonPrintableCharacterTexts", BindingFlags.Instance | BindingFlags.NonPublic) .GetValue(cachedElements); // ここで nonPrintableCharacterTexts を調整 cachedElements.GetType() .GetField("nonPrintableCharacterTexts", BindingFlags.Instance | BindingFlags.NonPublic) .SetValue(cachedElements, nonPrintableCharacterTexts);そのほか、TextLine を作るためにいくつかの非公開メソッドが必要になります。
これらも含めたものが、"結論" に載せたコードになります。