GUI プログラミング覚書

ここ最近,真面目に GUI を持つアプリケーションを書く機会が増えたので,現状までに気を付けないといけないなと感じたことをメモ.GUI アプリでまともに作ったものがまだ 2 つとかその程度なので,まだまだ足りない部分や勘違いも多そうですが気づいたらその都度修正すると言う事で.ちなみに,Visual Studio (.NetFramework) での経験を元に書くので VS (IDE) 前提の話になる部分もあります.

メインウィンドウとなるクラスの肥大化を防ぐ

まず,記述していてよく分かったのは,油断するとすぐにメインウィンドウに当たるクラス(Visual Studio の場合,よく Form1 とか MainForm とか名付けられるクラス)が肥大化してしまうと言う事です(これは,IDE のデザイナ経由で自動でコードが生成・挿入されてしまったり,ウィンドウ中の各種 GUI コンポーネントに依存していたり,などいろいろと理由は考えられます).

そのため,基本原則としてはできるだけメインウィンドウに当たるクラスの肥大化を防ぐ,もう少し具体的に言うと各種メソッドを切り離して別のクラスに持って行きやすいようにしておくと言う事を念頭に置いて記述していく必要があるなと感じました.特に,イベントハンドラ用のメソッドは「別に,メインウィンドウとなるクラスに定義しなくても良いなぁ」と後になって感じるものも多かったので注意して記述します.

メンバ変数を無闇やたらと直接参照しない

例えば,何らかの単一の機能を提供するクラスを定義する場合,そのクラスの各種メソッド内では特に深く考えずにメンバ変数を参照しても問題になる事はほとんどありませんでした.しかし,この感覚でウィンドウクラスを定義すると,肥大化したときに分割・再配置する際に問題になります*1

その中でも特に問題となるのは,メンバ変数として定義されている各種 GUI コンポーネントを参照する場合です.GUI アプリケーションでは大抵の場合,ユーザから入力される何らかの処理に必要な各種情報はそう言った GUI コンポーネントが保持しているので,ややもすると各種メソッド中でそれらの GUI コンポーネントのメンバ変数を参照してしまいがちです.しかし,いったん GUI コンポーネントのメンバ変数を参照してしまうと,そのメソッドはウィンドウと密接に関連してしまうため,クラスから分離できる可能性がほとんど消滅してしまいます.

using System.Windows.Forms;

public class Form1 : Form {
    private TextBox InputTextBox;
    private Label OutputLabel;
    private CheckBox IsOutputCheckBox;
    
    // このメソッドはクラスから分離するのが非常に困難になる
    private void f() {
        if (this.IsOutputCheckBox.Checked) {
            OutputLabel.Text = InputTextBox.Text;
        }
    }
}

そのため,方針としてはできるだけそう言った(メイン)ウィンドウに密接に関連してしまうような参照の仕方はしないと言う事になります.

メソッドの引数経由で参照する

クラス中の各種メソッド,特にインタフェースとしてユーザに公開する public メソッドではなく private メソッドを定義する場合,メソッド中で使用する情報をメンバ変数を直接参照するか,それとも引数経由で参照するかと言うのはよく悩みます.これは経験則ですが,何らかの単一の機能を提供するようなクラスでは多くの場合「どっちでも大差ない」と言う結果になります.恐らく,提供する機能が明確に単一に決まっているようなクラスの場合,いったん定義したメソッドを後になってクラス外(関数や他のクラスのメソッド)に再配置するような事は稀であるためだろうと思います.

しかし,ウィンドウクラスを記述する場合は,様々な理由でこのメソッドの再配置を行いたくなる場面に遭遇します.そのため,ウィンドウクラスでは,特にインターフェースに縛りのないような private メソッドなどには引数経由で渡せる情報はできるだけそうしておく方が後々になってプラスに働いてきます.

イベントハンドラの sender を意識する

C# を書き始めた頃,イベントハンドラの引数にある object sender は何者か分かりませんでした.この理由の一つとして,適当に C# のサンプルをググると sender 経由で参照できるものでもメンバ変数を直接参照しているサンプルが多いためと言うものがあります.

using System.Windows.Forms;

public class Form1 : Form {
    private TextBox InputTextBox;
    private Label OutputLabel;
    private CheckBox IsOutputCheckBox;
    
    private void IsOutputCheckBox_CheckedChanged(object sender, EventArgs e) {
        var checked = this.IsOutputCheckBox.Checked;
        
        // checked は以下のように sender 経由でも取得できる.
        // var control = sender as CheckBox;
        // var checked = control.Checked;
        
        if (checked) OutputLabel.Text = InputTextBox.Text;
    }
}

EventArgs などはそれ経由でしか得られない情報も多いので,多くのサンプルでもきちんと説明とともに使用されますが,第 1 引数の sender に関してはなまじ他の手段で取得する方法があるために無視されがちです.しかし,安易にメンバ変数を直接参照すると結合性が高まり,後々困る事になったりします.そのため,sender が何かを意識し,可能であればできるだけ sender 経由で情報を参照する方が良いです.

また,些細な事ですが,必要な情報がほとんどの GUI コンポーネントが保持しているような基本的なものである場合には,

// 他には,var control = sender as ScrollableControl 辺りは使用頻度が高いか.
var control = sender as Control

などのように記述しておくと,後々,新たな要求が発生して「似ているけどちょっと性質の異なる GUI コンポーネント」に置き換えた場合に,修正量が最小限で済む事もあります(この辺は,リファクタリングツールに任せる領分かもしれませんが).

GUI コンポーネントとメイン処理用クラスのインターフェースを検討する

GUI アプリの場合,(ユーザからの)入力値が GUI コンポーネント経由で渡されるため,ややもするとメイン処理をウィンドウクラス内に記述してしまいます.しかし,このスタイルはメインウィンドウのクラスばかりが肥大化する,テストコードが書けない状態に陥るなどいくつかの問題を引き起こします.そのため,GUI コンポーネントとメイン処理を別クラスに分離し,これら 2 つのクラスの橋渡しを行うためのインターフェースを検討する必要があります.

最近行った方法は,UserSetting と言うユーザの入力値のみを集めたクラスを定義し,メイン処理用のクラスは(コンストラクタなどで)そのクラスのインスタンスを渡すと言うものでした.UserSetting は,元々,レジストリなどに保存するための各種情報を保持するクラスだったのですが,こう言ったクラスを経由する事により GUI とメイン処理がうまく分離できる事もあります.

ただし,UserSetting のようなクラス経由でメイン処理クラスに各種情報を渡す方法は,「ユーザが必要な情報をポチポチ設定していって,最後に実行ボタンを押す」ような類のアプリケーションではうまくいきますが,もっとインタラクティブな操作を必要とするようなアプリケーションではなかなかうまく適用できません.この辺りは,アプリケーションの性質によってどのような形が最適か,その都度考えていかなければならない問題ではあります.

Tag の利用方法

System.Windows.Forms.Control 派生の各種 GUI コンポーネントは Tag と言うプロパティを持ち,ここには object クラス派生の任意のオブジェクトを設定して良い事になっています.各種 GUI コンポーネントを継承して新たなクラスとして定義する程ではないのだけど,何らかの追加的な情報を保持したいと言うときによく使用されます.

Tag に設定したい情報と言うのは開発が進むにつれて増えてくる事が多いです.そのため,どの位の情報を保持する必要があるかをよく検討して,必要であれば最初であれば Tag に各種情報を保持するためのクラスを新たに定義するなど利用方法をうまく考えていく必要があります.

おわりに

いろいろと書いてきましたが,GUI プログラミングに関してはまだまだ自分の経験が浅いのでこれから考え直す部分も数多く出てくるだろうと思います.その意味でも,あまり特定の考えに固執して極端になってしまう事は避けなければなぁと感じます.例えば,「メソッドの引数経由で参照する」と言う事に固執して,

HWND CreateWindowEx(
  DWORD dwExStyle, 
  LPCTSTR lpClassName,
  LPCTSTR lpWindowName,
  DWORD dwStyle,
  int x,
  int y,
  int nWidth,
  int nHeight,
  HWND hWndParent,
  HMENU hMenu,
  HINSTANCE hInstance,
  LPVOID lpParam
);

みたいなメソッドを定義し始めると,逆に分かりにくいと言う本末転倒の事態を招きます.この辺りのバランス感覚はなかなか明文化できる類のものではないですが,いつも気にしながら記述しかないとなと思います.

*1:こう言った問題に遭遇する理由の一つに,「ウィンドウ」を単位としてクラスを定義するのは,機能単位としては(さらに言うならば,人が理解するには)大きすぎると言う問題があるのかもしれません.