C++ ときどき ごはん、わりとてぃーぶれいく☆

USAGI.NETWORKのなかのひとのブログ。主にC++。

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 を共通の基底型に派生している」と思い込んでいました。実際には以下の通り:

TextBlockControl を経由せずに 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>();
}

とか ライブラリー実装しちゃう わけですよ😅

この実装では ItemsControlindex 番目の要素から nameName を持つ要素を探せなかった場合は null を想定していますが、「 ItemsControl から取り出せたアイテムは Control を共通基底型として扱えるハズ」という実装になっているため、実際には Control を基底型としない TextBox などが ItemsControlDataTemplate にある場合にその取得を試みると、アイテムは取得できるのに Control にキャストできないため as キャストで null が保持され、その nullName プロパティーを参照しようとして null 例外落ちする、というような現象が起こります。例えば↑のコード例の終端側から3行前の ToDictionary( c => c.Name ) で例外吐くとか。

ItemsControlDataTemplate の持つ要素に至るまではこの実装形態でのライブラリー側ではコンパイル時にプログラマーが「Control を共通の基底型として使えるハズ」と思い込んでいる状態ではコンパイルエラーやワーニングを吐くように実装できませんから、このライブラリーは使用者の実行時に意図しない例外を吐く上に、使い方によっては「どうしてそこで null 例外が飛んでるの😂」なんて思ってしまうような例外の吐かれ方にもなりえます。

特にライブラリーのリリースビルドだけを使うユーザーにとっては null 例外で落ちる部位で参照しているように見えるオブジェクトは非 null なために "ライブラリーを疑う" 選択肢に気づけないと悩みスパイラルデスマーチにリソースと精神を浪費してしまうかもしれない。しかも出る情報は NullReferenceException だけになったりもする:

f:id:USAGI-WRP:20181226034125p:plain

思い込み、こわいですね😅

今回の ItemsControl の使い方の想定では、 "いわゆるコントロール" は先の派生ツリーでも示した UIElement です。実際問題として LabelTextBox を扱うだけなら FrameworkElement でも意図通りに扱えますが、 ItemsControl のデータテンプレートから ContentPresenter を経由して Name で取得可能な要素は UIElement です。 FrameworkElementUIElement[RuntimeNameProperty( "Name" )] ほか "いわゆるコントロール" っぽいプロパティーを付けたりした "事実上の共通の基底型" です。必要に応じて as りましょう。

おわりに、だいじなこと、もう1回書いとこ。

思い込み、こわいですね😅

( ソフトウェアエンジニアにとって、 null よりも数段、よっぽどこわいものもある。プログラマーの思い込みによる実装は確実にそのうちの1柱だろう。特にコンパイルエラーやコンパイルワーニングをすり抜けてしまうと問題が起こるまでプログラマーは思い込みの上で安心しきり警戒すらしていないかもしれない。)