CDR6275

Michio SHIRAISHI Official Site

Java3Dプログラムのリファクタリング

東邦大学理学部情報科学科 白石路雄
最終更新:2012-10-03

1. はじめに

 Java3Dチュートリアルを読んで、複雑なシーンを作ろうとすると多くのプリミティブを扱う必要が出てくるため、うまくプログラムを書かないとすぐにプログラムがぐちゃぐちゃになってしまいます。本稿では、特にプログラミングを学びたての方が、書いてみたプログラムをよりよいプログラムにするための方法について述べます。

 プログラムをこのように書くと見通しよく書けますよ、という方法はあるのですが、それを学んでもいまいちピンと来ないのが一般的だと思います。一度ぐじゃぐじゃなプログラムを書いてみてから、どのようにしたらよりよいプログラムを書けるかを考える、という体験をしたほうがよいと私は考えています。そこで、ぐじゃぐじゃなプログラムをまず最初に紹介し、それを改善していく、という順番で述べていきます。

2. リファクタリング

2.1 題材

 大学の低学年の実習で、3時間の基礎の解説ののち、3時間の実習の時間で自由で作ってもらったプログラムを題材として取り上げます。実行した結果は次のようになります(実行した後にジオメトリが分かるようにマウスで操作しています)。

Image of GameConsole.java

 一目瞭然で携帯ゲーム機ですね。次に、このオブジェクトを構成しているプリミティブについて説明します。プリミティブは全部で8個あり、次のように分類することができます。

  • 上部:ベースとなる直方体とディスプレイとなる直方体があります
  • 下部:ベースとなる直方体とディスプレイとなる直方体があります
  • ボタン:右側に2つあり、円筒で表現されています
  • 十字キー:左側にあり、2つの直方体を交差させて表現しています

 最初のプログラムはこちらにあります。一通り眺めてみてください。

2.2 問題点

 プログラムを眺めていくつか問題点があることが分かります。具体的に挙げると、

  • createPrimitivesメソッドが長いので、直そうと思ったときにスクロールして直すべき場所を探さないといけない
  • tg1, tg2, …, tg8」のように番号で管理されているため、どの番号がどの部品に対応しているのかが分かりにくい
  • 同じことを行っている部分があり、直そうと思ったときに何カ所も直さないといけない
  • 謎の数字がたくさんでてきている

 いまのところプリミティブの個数が8個なので、まだなんとかなります。ただ、これが100個、10000個、10000個、…と増えていった場合どうなるでしょうか。

2.3 リファクタリング

 リファクタリングとは、「コンピュータプログラミングにおいて、プログラムの外部から見た動作を変えずにソースコードの内部構造を整理すること(Wikipedia)」です。この例では、携帯ゲーム機の見た目は変えずに、プログラムの書き方を変えて、よりよいプログラムにすることを意味します。

 さて、ここまでで「よいプログラム」という表現を使ってきましたが、具体的にはどのようなプログラムが「よい」のでしょうか。よいプログラムの指標にはいくつかあります。同じことをやるのであれば速いほうがよいですし、バグが少ない方がよいですし、機能を拡張することを考えると変更しやすいプログラムがよいです。ここでは「プログラムの構造を人間が理解しやすいプログラムがよい」ということにします。人間が理解できれば、速いアルゴリズムに置き換えることや、バグを取り除くことや、機能を拡張することが容易になります。

 本稿では、次の2つの方針から、リファクタリングを行います。まず第一に、実行するプログラムはまったく変えずに同じことを行うプログラムに書き換えていくことで、構造を理解しやすいプログラムを作る、という方針です。3節では、この方針に基づいてリファクタリングを行っていきます。

 第二に、アルゴリズムを変えて、同じ結果が得られるプログラムをより構造的に書く、という方針です。4節では、この方針に基づいてリファクタリングを行っていきます。

3. 同じことをするプログラムに書き換える

3.1 コメントを入れよう

 コメントを入れてもプログラムの動作は全く変わりません。他の人にプログラムを読んでもらう際にもコメントという自然言語で説明してあったほうが親切ですし、そもそも自分で書いたプログラムはすぐ忘れて「あれこれは何をしたかったんだろう」ということがよくあります。コメントをいれた例をこちらに示します。コメントを入れるだけでもずいぶんと違いますよね。

3.2 名前重要

 変数には変数名、メソッドにはメソッド名、クラスにはクラス名をつけることができます。これらの名前ですが、その変数が表す意味やそのメソッドが持つ意味をよく考えてつけるようにしましょう。Rubyという言語を開発したまつもとさんも名前重要と言っております。私の場合も、名前をつけるときには一呼吸置いて考えます。

 そもそも「tg1, tg2, …, tg8」と並べるときは、「同じものが並んでいるときに使う」ものです。この例では、それぞれの部品は同じものではないので、違う名前にするべきです。

 同じものが並んでいるときには、番号は使うこともあります。たとえば、1000個のポリゴンからなる3Dモデルのそれぞれのポリゴンに名前をつけるのは無意味です。ですが、その場合でも配列を使うようにしましょう。

 変数に適切な名前をつけるために、まず部品の名前を考えましょう。ここでは以下のようにしました。

  • 上部ベース:upperBase
  • 上部ディスプレイ:upperDisplay
  • 上部ベース:lowerBase
  • 上部ディスプレイ:lowerDisplay
  • 左下ボタン:button1
  • 右上ボタン:button2
  • 十字キー縦:cross1
  • 十字キー横:cross2

 1と2とか使ってるじゃん、と言われそうですが、2個ぐらいだったらいいような気がしますし、2つのボタンを製造する工程を考えると同じ金型から作られるはずだし、そういった観点では「同じもの」とみなすことができるかな、と思ってこうしました。

 この名前を使って、プログラムを書き換えた例をこちらに示します(上部ベース、上部ディスプレイ、および、上部ベースの部分について変更しました)。ついでに上部で宣言されていたTransformGroupを実際に使われるところに持ってきました。

3.3 メソッドを使って一連の処理をまとめる

 メソッドをいくつかの処理をまとめるのに有効です。メソッドには名前をつけられる、という特徴がありますので、一連の処理を分かりやすい名称で呼ぶことができます。

 メソッドを作るときに注意しなければならないのは、メソッド内で宣言した変数はメソッドの内部でのみ使えるという点です。逆に言えば、「まとめようと思っている一連の処理以外の部分でも使用してなければ、その部分はメソッドの内部に閉じ込めておける、ということです。具体例を見てみましょう。createPrimitivesメソッドは次のようになっています。

	public void createPrimitives(TransformGroup tg){
		// 上部ベース部分
		Appearance appearanceUpperBase = new Appearance();
		Material materialUpperBase = new Material();
		materialUpperBase.setDiffuseColor(1.0f, 0.0f, 0.2f);
		appearanceUpperBase.setMaterial(materialUpperBase);
		Box boxUpperBase = new Box(0.18f, 0.10f, 0.01f, Box.GENERATE_NORMALS, appearanceUpperBase);
		TransformGroup tgUpperBase = new TransformGroup();
		tgUpperBase.addChild(boxUpperBase);
		// 座標変換(平行移動)
		Transform3D transform3DUpperBase = new Transform3D();
		transform3DUpperBase.setTranslation(new Vector3d(0.0f, 0.215f, 0.025f));
		tgUpperBase.setTransform(transform3DUpperBase);
		tg.addChild(tgUpperBase);
    ...
  }

ここで、appearanceUpperBasematerialUpperBaseboxUpperBasetgUpperBase、および、transform3DUpperBaseの4つの変数は、これ以降のプログラム(上記のコード中では「…」で示しました)では使われていません。したがって、次のようにメソッドにすることができます。

	public void createPrimitives(TransformGroup tg){
	  createUpperBase(tg);
	  ...
  }
  private void createUpperBase(TransformGroup tg){
		// 上部ベース部分
		Appearance appearanceUpperBase = new Appearance();
		Material materialUpperBase = new Material();
		materialUpperBase.setDiffuseColor(1.0f, 0.0f, 0.2f);
		appearanceUpperBase.setMaterial(materialUpperBase);
		Box boxUpperBase = new Box(0.18f, 0.10f, 0.01f, Box.GENERATE_NORMALS, appearanceUpperBase);
		TransformGroup tgUpperBase = new TransformGroup();
		tgUpperBase.addChild(boxUpperBase);
		// 座標変換(平行移動)
		Transform3D transform3DUpperBase = new Transform3D();
		transform3DUpperBase.setTranslation(new Vector3d(0.0f, 0.215f, 0.025f));
		tgUpperBase.setTransform(transform3DUpperBase);
		tg.addChild(tgUpperBase);
  }

このようにすることで、createPrimitivesメソッドには、どの部品を作るかというメソッドの呼び出しを8個並べることになり、8個の部品を順番に作っている、ということが明確になります。また、それぞれの部品を作るメソッドの中では、その部品を作ることだけに集中して、プログラムを書くことができます。さらに、createUpperBaseメソッドの内部の変数は他とは異なるものになるので、もっと短い変数名をつけることができます。そこで次のように書き換えることができます。

	public void createPrimitives(TransformGroup tg){
	  createUpperBase(tg);
	  ...
  }
  private void createUpperBase(TransformGroup tg){
		// 上部ベース部分
		Appearance appearance = new Appearance();
		Material material = new Material();
		material.setDiffuseColor(1.0f, 0.0f, 0.2f);
		appearance.setMaterial(material);
		Box box = new Box(0.18f, 0.10f, 0.01f, Box.GENERATE_NORMALS, appearance);
		TransformGroup newTg = new TransformGroup();
		newTg.addChild(box);
		// 座標変換(平行移動)
		Transform3D transform = new Transform3D();
		transform3DUpperBase.setTranslation(new Vector3d(0.0f, 0.215f, 0.025f));
		newTg.setTransform(transform3D);
		tg.addChild(newTg);
  }

 このような手順で書き換えたプログラムをこちらに示します。createPrimitivesメソッドの中を見れば、どのような部品が作られているのかが分かりますし、具体的にどのように作られているかは、それぞれのメソッドの中を見れば分かります。

3.4 メソッドを使って同じ処理をまとめる

 次に同じ処理をしている部分を探してみます。すると、

material.setDiffuseColor(1.0f, 0.0f, 0.2f);

という行が2カ所にあることが分かります。これはベースの色を決めている部分ですが、上部と下部のベースの色が同じであることを意味しています。この部分をまとめるためにメソッドを使うことができます。このメソッドではmaterialに色を設定すればいいのですから、引数としてMaterialクラスのインスタンスを受け取ればいいので、次のように書くことができます。

private void applyBaseColor(Material material) {
		material.setDiffuseColor(1.0f, 0.0f, 0.2f);
}

そして、もともと、

material.setDiffuseColor(1.0f, 0.0f, 0.2f);

となっていた2カ所の部分は、

applyBaseColor(material);

となります。書き換えることにより、「なんだか謎な色を設定している」のではなく、「ベースとなる色を設定している」ことが分かりやすくなります。このようにして、色の部分(ベースとディスプレイとボタン/十字キー)を設定するメソッドを取り出して、書き直した例をこちらに示します。

3.5 メソッドを使って同じインスタンスを作る

 下部の部品については、4つ(ボタンと十字キー)がx軸に関して45度回転させるtransformRotation、および、2つ(下部ベースと下部ディスプレイ)がx軸に関して-45度回転させるtransformRotationを利用しています。毎回Transform3Dクラスをnewするのも面倒ですし、まとめてみたいと思います。

 ここでは、新たにTransform3Dクラスのインスタンスを作成して、それを利用します。このことからメソッド内部でnewして作成したインスタンスを戻り値として返すメソッドを用いればよいことに気づきます。したがって、

Transform3D transformRotation = new Transform3D();
transformRotation.setRotation(new AxisAngle4f(1.0f, 0.0f, 0.0f, (float)Math.PI/4.0f));

の部分について、

private Transform3D createTransformRotation() {
  Transform3D transformRotation = new Transform3D();
  transformRotation.setRotation(new AxisAngle4f(1.0f, 0.0f, 0.0f, (float)Math.PI/4.0f));
  return transformRotation;
}

というメソッドを作り

Transform3D transformRotation = new Transform3D();
transformRotation.setRotation(new AxisAngle4f(1.0f, 0.0f, 0.0f, (float)Math.PI/4.0f));

の2行を

Transform3D transformRotation = createTransformRotation();

と書き換えることができます。このようにして書き換えたプログラムをこちらに示します。このプログラムでは、-45度の回転も同時に扱えるように、引数を1つ渡すように変更してあります。

3.6 クラスを使ってインスタンスをまとめる (1)

 それぞれの部品を作る際には、似たようなことをしていることが分かります。すなわち、Appearanceを作って、Materialを作り、色を設定してから、プリミティブを作る、という手順です。この部分を共通化できないでしょうか。

 このためにはクラスを作ります。ApperanceMaterialはそれぞれのプリミティブに1つずつ割り当てられており、クラスのインスタンス変数とする対象となります。そこで、次のようなクラスを作りました。

public class GameConsoleApperance {
	private Appearance appearance;
	private Material material;
	
	public GameConsoleApperance(){
		appearance = new Appearance();
		material = new Material();
		appearance.setMaterial(material);
	}

	public Appearance getAppearance() {
		return appearance;
	}

	public Material getMaterial() {
		return material;
	}
}

ここで、GameConsoleApperanceと名づけたのは、通常のAppearanceに加えて、Materialも含めた見た目、という意味でつけました。このようなクラスを用いれば

		Appearance apperance = new Appearance();
		Material material = new Material();
		applyDisplayColor(material);
		apperance.setMaterial(material);
		Box box = new Box(0.12f, 0.08f, 0.01f, Box.GENERATE_NORMALS, apperance);

となっていた部分が

		GameConsoleApperance appearance = new GameConsoleApperance();
		applyBaseColor(appearance.getMaterial());
		Box box = new Box(0.18f, 0.10f, 0.01f, Box.GENERATE_NORMALS, appearance.getAppearance());

となります。

 さらにapplyDisplayColorメソッドは、Materialだけに関係するので、GameConsoleApperanceクラスのメソッドにしてしまいます。すると、

		GameConsoleApperance appearance = new GameConsoleApperance();
		appearnce.applyBaseColor();
		Box box = new Box(0.18f, 0.10f, 0.01f, Box.GENERATE_NORMALS, appearance.getAppearance());

と書くことができます。このようにして書き直した例をこちらに示します。

3.7 クラスを使ってインスタンスをまとめる (2)

 さらにクラスを使ってインスタンスをまとめてみましょう。次にまとめたいのが、BoxあるいはCylinderという2種類あるプリミティブです。これらをまとめたいところですが、直方体と円柱は違うものなので、GameConsoleBoxGameConsoleCylinderという2つのクラスに分けて考えましょう。ただし、これらのクラスはGameConsoleApperanceを共通して持ちますので、親クラスとしてGameConsolePrimitveというクラスを作り、このクラスにGameConsoleApperanceを持たせることにします。

 また、プログラム中はいくつかのBoxが作られていますが、それらの間で異なる部分は大きさだけで、それ以外は共通です。したがって、次のようなクラスを作ることができます。

public class GameConsolePrimitive {
	protected GameConsoleApperance appearance;
	public GameConsolePrimitive(){
		appearance = new GameConsoleApperance();
	}
	public GameConsoleApperance getApperance(){
		return appearance;
	}
}
public class GameConsoleBox extends GameConsolePrimitive{
	private Box box;
	public GameConsoleBox(float x, float y, float z){
		super();
		box = new Box(x, y, z, Box.GENERATE_NORMALS, appearance.getAppearance());
	}
	public Box getBox(){
		return box;
	}
}
public class GameConsoleCylinder extends GameConsolePrimitive{
	private Cylinder cylinder;
	public GameConsoleCylinder(float radius, float height){
		super();
		cylinder = new Cylinder(radius, height, Cylinder.GENERATE_NORMALS, 30, 30, appearance.getAppearance());
	}
	public Cylinder getCylinder(){
		return cylinder;
	}
}

 ここまでの変更を加えたプログラムをこちらに示します。

3.8 座標変換をクラスに任せる

 さて、あとは座標変換の部分です。以下の2つのメソッドを見てみましょう。

	private void createUpperBase(TransformGroup tg) {
		GameConsoleBox box = new GameConsoleBox(0.18f, 0.10f, 0.01f);
		box.getApperance().applyBaseColor();
		TransformGroup tgNew = new TransformGroup();
		tgNew.addChild(box.getBox());
		// 座標変換(平行移動)
		Transform3D transform = new Transform3D();
		transform.setTranslation(new Vector3d(0.0f, 0.215f, 0.025f));
		tgNew.setTransform(transform);
		tg.addChild(tgNew);
	}
	
	private void createUpperDisplay(TransformGroup tg) {
		GameConsoleBox box = new GameConsoleBox(0.12f, 0.08f, 0.01f);
		box.getApperance().applyDisplayColor();
		TransformGroup tgNew = new TransformGroup();
		tgNew.addChild(box.getBox());
		// 座標変換(平行移動)
		Transform3D transform = new Transform3D();
		transform.setTranslation(new Vector3d(0.0f, 0.22f, 0.04f));
		tgNew.setTransform(transform);
		tg.addChild(tgNew);
	}

 非常に似ています。違うところは、与えている平行移動量の部分のみです。したがって、それらの部分をまとめてGameConsoleBoxに押しやりたいです。ここで注目したいのは、tgNewを新しく作って、tgに接続している部分です。そこで、GameConsoleBoxにそれを持たせることを考えます。

 ただ、ここで、プログラム全体を見渡すと、Cylinderもありますので、それらの親クラスであるGameConsolePrimitiveに持たせておいたほうがよさそうです。

 そこで次のようなコードをGameConsolePrimitiveに持たせます。

public class GameConsolePrimitive {
	private TransformGroup tg;
	protected GameConsoleApperance appearance;
	public GameConsolePrimitive(){
		appearance = new GameConsoleApperance();
	}
	public GameConsoleApperance getApperance(){
		return appearance;
	}
	public TransformGroup getTg(){
		return tg;
	}
	public void applyTranslation(float x, float y, float z){
		tg = new TransformGroup();
		Transform3D transform = new Transform3D();
		transform.setTranslation(new Vector3d(x, y, z));
		tg.setTransform(transform);
	}
}

このようなメソッドを作ると、createUpperBasecreateUpperDisplayの2つのメソッドはこれだけになります。

	private void createUpperBase(TransformGroup tg) {
		GameConsoleBox box = new GameConsoleBox(0.18f, 0.10f, 0.01f);
		box.getApperance().applyBaseColor();
		box.applyTranslation(0.0f, 0.215f, 0.025f);
		box.getTg().addChild(box.getBox());
		tg.addChild(box.getTg());
	}
	
	private void createUpperDisplay(TransformGroup tg) {
		GameConsoleBox box = new GameConsoleBox(0.12f, 0.08f, 0.01f);
		box.getApperance().applyDisplayColor();
		box.applyTranslation(0.0f, 0.22f, 0.04f);
		box.getTg().addChild(box.getBox());
		tg.addChild(box.getTg());
	}

 同様にして、下部部品の回転移動→平行移動もできるようにするには、GameConsolePrimitiveに次のようなメソッドを用意し、

public class GameConsolePrimitive {
	private TransformGroup tg;
	protected GameConsoleApperance appearance;
	public GameConsolePrimitive(){
		appearance = new GameConsoleApperance();
	}
	public GameConsoleApperance getApperance(){
		return appearance;
	}
	public TransformGroup getTg(){
		return tg;
	}
	public void applyTranslation(float x, float y, float z){
		tg = new TransformGroup();
		Transform3D transform = new Transform3D();
		transform.setTranslation(new Vector3d(x, y, z));
		tg.setTransform(transform);
	}
	public void applyRotationAndTranslation(boolean direction, float x, float y, float z){
		tg = new TransformGroup();
		Transform3D transform = new Transform3D();
		transform.setTranslation(new Vector3d(x, y, z));
		Transform3D transformRotation = createTransformRotation(false);
		transformRotation.mul(transform);
		tg.setTransform(transformRotation);
	}
	private Transform3D createTransformRotation(boolean flag) {
		Transform3D transformRotation = new Transform3D();
		transformRotation.setRotation(new AxisAngle4f(1.0f, 0.0f, 0.0f, (flag?1:-1)*(float)Math.PI/4.0f));
		return transformRotation;
	}
}

最終的に書き換えたのが、こちらです。

3.9 まとめ

 最終的にこちらのプログラムを書き換えた結果がこちらになります。GameConsole.javaが167行だったのに対して、このファイルがGameConsole9.java(125行)、GameConsoleAppearance.java(29行)、GameConsoleBox.java(12行)、GameConsoleCylinder.java(12行)、および、GameConsolePrimitive.java(35行)の5つのファイルに増え、トータルで223行となっているため、一見すると複雑になったように思えます。ただ、このようにしておくと、下部部品を追加するの必要な行数が、GameConsole.javaが14行なのに対して、GameConsole9.javaが7行で済みます。さらに、必要な情報だけを設定すればよいようになったので、コピー&ペーストでプリミティブを増やしていくよりも、よりバグが少なくプログラミングを行えます。

4. オブジェクト指向的な考え方によるプログラミング

 3節書くのに疲れたので、気が向いたらそのうち書きます。シーングラフをちゃんと使えば、ひとつひとつの部品を回転させなくても済むし、位置合わせも楽だよね、という話。

5. おわりに

 3節書くのに疲れたので、気が向いたらそのうち書きます。