C#, WPF, XAML: System.Windows.Controls.Control が "いわゆるコントロール" の共通基底だと思い込んで実装したら TextBlock であっさり null 例外に殺された件
// 前提、 C#-7.0 using System.Windows.Controls;
var c = new Control(); if ( c is Label l ) Console.WriteLine( $"Label.Content={l.Content}" ); if ( c is TextBlock t ) // <--ここでコンパイルエラーが生じてくれる。 Console.WriteLine( $"TextBlock.Text={t.Text}" );
c is TextBlock t
に対し error CS0584: Internal compiler error: constant in type pattern matching
が発生し、このコードはコンパイルできません。
てっきり「 XAML で使えるような部品としての意味での "いわゆるコントロール" は Control
を共通の基底型に派生している」と思い込んでいました。実際には以下の通り:
- System.Windows.Threading.DispatcherObject
TextBlock
は Control
を経由せずに Control
の基底にもなっている FrameworkElement
から派生していたんですね😂
最初のコード例のような実装ではコンパイラーがエラーを出してくれるのでプログラマーはどんなに間抜けでも実装をリリースできないので安心、見るからにすぐに気づいて修正できる問題に見えます。しかし、これが ItemsControl
のデータテンプレートから展開される "いわゆるコントロール"(上記の通り実際には Control
とは限らない)を扱う実装を書いてみると、この問題に侵された実装をリリースできてしまう事があります。と、いうかありました😅
// "いわゆるコントロール" を Control だと思い込んだ人の書いたやゔぁいライブラリー実装💀 // ItemsControl から index 番目の ContentPresenter を取得 static public ContentPresenter GetContentPresenter ( ItemsControl control, int index ) { return control .ItemContainerGenerator .ContainerFromIndex( index ) as ContentPresenter ; } // ContentPresenter で Name の "いわゆるコントロール" を取得 static public T FindControlAs<T> ( ContentPresenter presenter, string name ) where T : Control { return presenter .ContentTemplate .FindName( name, presenter ) as T ; } // ItemsControl に GetContentPresenter + FindControlAs の複合技で // index 番目の "いわゆるコントロール" を取得 static public T FindControlAs<T> ( ItemsControl control, int index, string name ) where T : Control { if ( GetContentPresenter( control, index ) is ContentPresenter p ) return FindControlAs<T>( p, name ); return null; } // ItemsControl の index 番目のアイテム(≃データテンプレート)から // 複数の Name 群 names にそれぞれ対応した "いわゆるコントロール" を // 取得して Name => "いわゆるコントロール" の辞書にしてくれる便利機能さん static public IDictionary<string, T> FindControlsAs<T> ( ItemsControl control, int index, params string[] names ) where T : Control { if ( GetContentPresenter( control, index ) is ContentPresenter p ) { return ( from name in names select FindControlAs<T>( p, name ) ).ToDictionary( c => c.Name ); } return new Dictionary<string, T>(); }
とか ライブラリー実装しちゃう わけですよ😅
この実装では ItemsControl
の index
番目の要素から name
な Name
を持つ要素を探せなかった場合は null
を想定していますが、「 ItemsControl
から取り出せたアイテムは Control
を共通基底型として扱えるハズ」という実装になっているため、実際には Control
を基底型としない TextBox
などが ItemsControl
の DataTemplate
にある場合にその取得を試みると、アイテムは取得できるのに Control
にキャストできないため as
キャストで null
が保持され、その null
へ Name
プロパティーを参照しようとして null
例外落ちする、というような現象が起こります。例えば↑のコード例の終端側から3行前の ToDictionary( c => c.Name )
で例外吐くとか。
ItemsControl
の DataTemplate
の持つ要素に至るまではこの実装形態でのライブラリー側ではコンパイル時にプログラマーが「Control
を共通の基底型として使えるハズ」と思い込んでいる状態ではコンパイルエラーやワーニングを吐くように実装できませんから、このライブラリーは使用者の実行時に意図しない例外を吐く上に、使い方によっては「どうしてそこで null
例外が飛んでるの😂」なんて思ってしまうような例外の吐かれ方にもなりえます。
特にライブラリーのリリースビルドだけを使うユーザーにとっては null
例外で落ちる部位で参照しているように見えるオブジェクトは非 null
なために "ライブラリーを疑う" 選択肢に気づけないと悩みスパイラルデスマーチにリソースと精神を浪費してしまうかもしれない。しかも出る情報は NullReferenceException
だけになったりもする:
思い込み、こわいですね😅
今回の ItemsControl
の使い方の想定では、 "いわゆるコントロール" は先の派生ツリーでも示した UIElement
です。実際問題として Label
と TextBox
を扱うだけなら FrameworkElement
でも意図通りに扱えますが、 ItemsControl
のデータテンプレートから ContentPresenter
を経由して Name
で取得可能な要素は UIElement
です。 FrameworkElement
は UIElement
に [RuntimeNameProperty( "Name" )]
ほか "いわゆるコントロール" っぽいプロパティーを付けたりした "事実上の共通の基底型" です。必要に応じて as
りましょう。
おわりに、だいじなこと、もう1回書いとこ。
思い込み、こわいですね😅
( ソフトウェアエンジニアにとって、 null
よりも数段、よっぽどこわいものもある。プログラマーの思い込みによる実装は確実にそのうちの1柱だろう。特にコンパイルエラーやコンパイルワーニングをすり抜けてしまうと問題が起こるまでプログラマーは思い込みの上で安心しきり警戒すらしていないかもしれない。)