Javaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その2)
前回のJavaプログラミング能力認定試験課題プログラムのリファクタリングレポート(その1)に引き続き、試験問題のリファクタリングについて考えます。
画面入出力処理の抽象化とカプセル化
前回はこの試験問題でもっともコーディングが面倒なファイル入出力関連の処理をRepositoryというインターフェース型で抽象化することで、処理が共通化されるだけでなく、全体のプログラムの記述が簡易化されることを説明しました。この試験問題のプログラムでファイル入出力とならんでもう一つ面倒なのは、コンソールを使ったユーザーとの対話処理を行う部分です。この処理は一見単純そうですが、不正な入力値があった場合に処理を繰り返すなど、COBOL級の分岐とループ構造だけで表すと処理が結構複雑化してしまいます。実際、オリジナルのソースでは、以下のようなきわめて複雑な多重ループ構造がいたるところに出現します。
(Javaプログラミング能力認定試験1級問題より引用) //データのある人材IDが入力されるまでループ DELETEKID: while( true ) { String jID; //人材ID while( true ) { //人材IDが正しく入力されるまでループ jID = displayMessage( KD_MESS.GETID ); //人材IDの取得 if( (new JDisplay(jID).getjData()) != null ) { break; } } KDisplay kdisplay = new KDisplay(jID); while( true ) { //人材IDの取得 this.kData = kdisplay.startThis(); //稼働状況を表示 if( kData != null ) { No = displayMessage( KD_MESS.GETKID ); //削除する稼働状況IDの取得 if (chkKData(No)) break DELETEKID; } else { continue DELETEKID; } } } String YorN = displayMessage( KD_MESS.GETYN ); while( true ) { if( YorN.equals("Y") ) { if( deleteData(No) ) System.out.println( "削除しました。" ); //削除日付の格納 else System.out.println( "削除できませんでした。" ); break; } else if( YorN.equals("N") ) break; else YorN = displayMessage( KD_MESS.R_GETYN ); }
以上のコードは多重の無限ループとなっており、多重ループを抜けるためにラベル付きbreak文(goto文に相当)が使われています。COBOL時代はこうした複雑なコードを一瞬で作成、理解できたら、相当の達人プログラマーとされていたのかもしれません。しかし、少なくともJavaはオブジェクト指向言語です。このような複雑なコードの作成と保守に余分な神経をすり減らすのではなく、適切なインターフェースを抽出することではるかに楽ができます。
前回ファイル入出力に対してRepositoryというインターフェースを考えたのと同じ要領で、コンソールを使ったユーザーとの対話処理を抽象化するConsoleというインターフェースを作成することを考えます。既存のソースコードを読むとわかりますが、このインターフェースには以下のような機能を持たせます。
- 単に文字列をメッセージとして表示する。
- 文字列を入力する。
- 文字列を入力するが、正しい入力が得られるまで入力を繰り返す。
- 数値を入力する。
- 日付を入力する。
- 選択肢を表示し、その中から一つの項目を選択する。
- はい、いいえを選択する。
Javaのインターフェースとしては具体的に以下のようなものを作成することになります。
package sample.common.console; import java.util.Date; import java.util.List; import sample.common.entity.Identifiable; import sample.common.entity.NameId; public interface Console { /** * メッセージを表示する。 * @param message 表示対象メッセージ */ void display(String message); /** * YesNoの選択メッセージを表示する。 * @param message 表示対象メッセージ * @return Yesが選択された場合はtrue */ boolean confirm(String message, String yes, String no); /** * メッセージとともに入力プロンプトを表示し、標準入力からの入力を受け付ける * * @param 表示メッセージ * @return 入力文字列 */ String accept(String message); /** * メッセージとともに入力プロンプトを表示し、標準入力からの入力を受け付ける。 * 正しい入力値が得られるまで、再度入力を繰り返す。 * * @param 表示メッセージ * @return 入力文字列 */ String accept(String message, ValidInput<String> validInput); /** * メッセージとともに入力プロンプトを表示し、標準入力からの整数入力を受け付ける * * @param 表示メッセージ * @return 入力値 */ int acceptInt(String message); int acceptInt(String message, ValidInput<Integer> validInput); /** * メッセージとともに入力プロンプトを表示し、標準入力からの長整数入力を受け付ける * * @param 表示メッセージ * @return 入力値 */ long acceptLong(String message); long acceptLong(String message, ValidInput<Long> validInput); /** * yyyyMMdd書式で日付を入力する。正しい日付が入力されるまで処理を繰り返す。 * * @param message 表示メッセージ * @return 入力された日付 */ Date acceptDate(String message); /** * 日付を入力する。正しい日付が入力されるまで処理を繰り返す。 * * @param message 表示メッセージ * @param format 日付フォーマット * @return 入力された日付 */ Date acceptDate(String message, String format); /** * メッセージとともに選択肢のリストを表示する。 * 選択結果を返す。正しい選択結果が入力されるまで、内部で再入力を促す。 * * @param selectList * @param message * @return 選択結果 */ String acceptFromNameIdList(List<? extends NameId<?>> selectList, String message); String acceptFromIdList(List<? extends Identifiable<?>> selectList, String message); String acceptFromList(List<String> selectList, String message); }
文字列の出力をdisplay、入力をacceptとしたのは、あえてCOBOL風のボキャブラリーを利用するちょっとした遊び心からです。このクラスを実装してしまえば、基本的にあらゆる機能から再利用することができます。実際、前回のRepositoryによるファイル入出力の抽象化と合わせることで、「稼動」の登録処理は、以下のようにきわめて簡単に記述できます。
package sample.app.work_management; import java.io.File; import sample.common.console.Console; import sample.common.console.ConsoleImpl; import sample.common.console.ValidInput; import sample.common.io.CharSeparatedFileRepository; import sample.common.program.Function; import sample.domain.HumanResource; import sample.domain.Partner; import sample.domain.Work; /** * 稼働状況入力 */ public class InputWorkFunction implements Function { // TODO DI化 private CharSeparatedFileRepository<Work> workRepository = new CharSeparatedFileRepository<Work>(); private CharSeparatedFileRepository<HumanResource> hrRepository = new CharSeparatedFileRepository<HumanResource>(); private CharSeparatedFileRepository<Partner> partnerRepository = new CharSeparatedFileRepository<Partner>(); private Console console = new ConsoleImpl(); public InputWorkFunction() { workRepository.setMasterFile(new File("kadou.txt")); workRepository.setWorkFile(new File("kadou.tmp")); partnerRepository.setMasterFile(new File("torihiki.txt")); partnerRepository.setWorkFile(new File("torihiki.tmp")); } /** * 稼働状況管理(追加)の実行 */ public void run() { Work work = inputData(); doCreate(work); } /** * 稼働状況の入力 */ private Work inputData() { Work work = new Work(); long hrId = console.acceptLong("人材IDを入力してください。", new ValidInput<Long>() { @Override public boolean isValid(Long input) { // 人材ID存在チェック return hrRepository.findById(input) != null; } }); work.setHrId(hrId); work.setPartnerId(console.acceptFromNameIdList(partnerRepository.findAll(), "取引先を選択してください。")); work.setStartDate(console.accept("稼動開始日を入力してください。")); work.setEndDate(console.accept("稼動終了日を入力してください。")); work.setContractSalary(console.accept("契約単価を入力してください。")); return work; } /** * 稼働状況のファイルへの登録 */ private void doCreate(Work work) { workRepository.create(work); console.display("登録されました。"); } }
このプログラムなら、よほどコードの読解が遅い人でも数分もあれば完全に内容を把握できると思います。複雑な処理を抽象化したことで、「入力を受け取る」「ファイルに登録する」など外部仕様書に近い非常に高いレベルでプログラムが記述されていることに注目してください。COBOL時代ではいかに大量のコードを速く記述し、理解できるかというところがプログラマーの能力だったのかもしれませんが、オブジェクト指向言語の場合は、いかにコードを単純にわかりやすく記述できるかというところがプログラマーにもっとも要求される能力になるのです。
参考までに、上記と同じ機能を実現しているオリジナルのソースを以下に引用しておきます。
(Javaプログラミング能力認定試験1級問題より引用) /* KInput.java */ import java.io.BufferedReader; import java.io.FileReader; import java.io.File; import java.io.IOException; import java.io.BufferedWriter; import java.io.FileWriter; import java.io.FileNotFoundException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.ArrayList; /** 稼働状況入力 */ class KInput extends GChars { /** メッセージ選択用列挙型定数 */ enum KI_MESS { GETID } /** 稼働状況管理(追加)の実行 */ void startThis() { String jID; //人材ID while( true ) { //人材IDが正しく入力されるまでループ jID = displayMessage( KI_MESS.GETID ); //人材IDの取得 if( (new JDisplay(jID).getjData()) != null ) { break; } } if( writeData(jID,getData(getTList())) ) { System.out.println( "登録されました。" ); } } /** 人材IDと稼働状況マスタを照合,格納されている最大の稼働状況番号を検索し,それに * 1を加えた値を入力された稼働状況に割り当てて稼働マスタに出力 * @param jID 人材IDを表す文字列 * @param inData 入力された稼働状況を表す文字列配列 * @return 正常終了フラグ */ boolean writeData( String jID,String[] inData ) { File textFile = new File( "kadou.txt" ); File tempFile = new File( "kadou.tmp" ); try { BufferedReader br = new BufferedReader( new FileReader(textFile) ); //稼働状況マスタを開く BufferedWriter bw = new BufferedWriter( new FileWriter(tempFile) ); //テンポラリーファイルを開く String instr, ID, strtmp; int No = 0, mNo = 0; //稼働状況マスタから1レコードずつ読込み while( (instr=br.readLine()) != null ) { ID = instr.substring( 0, instr.indexOf('\t') ); //人材IDの取出し if( ID.equals(jID) ) { //人材IDが一致 strtmp = instr.substring( instr.indexOf('\t')+1, instr.length() ); No = Integer.parseInt( strtmp.substring( 0, strtmp.indexOf('\t') ) ); //稼働状況番号の取出し if( No > mNo ) { mNo = No; //最大の稼働状況番号の取得 } } bw.write( instr ); //人材情報の転記 bw.newLine(); //改行 } bw.write( jID+"\t"+(++mNo)+listtoStr_t(inData) ); //新しいデータの書込み bw.newLine(); //改行 br.close(); bw.close(); textFile.delete(); tempFile.renameTo( textFile ); //テンポラリーファイルを稼働状況マスタに置換え return true; } catch( IOException e ) { //稼働状況マスタへのアクセスエラー System.err.println( e.getMessage() ); } return false; } /** 稼働状況の入力 * @param TList 取引先リストを表す文字列配列 * @return 入力された稼働状況情報 */ String[] getData( String[][] TList ) { String inData[][] = { { "取引先ID", "稼働開始日", "稼働終了日", "契約単価" }, { "", "", "", "" } }; for( int i = 0; i < inData[0].length; i++ ) { if( inData[0][i].equals("取引先ID") ) { RIGHTTID: while( true ) { //有効な取引先IDが入力されるまでループ System.out.println( "取引先を選択してください。" ); System.out.print( listtoStr_t(TList) ); inData[1][i] = getChars(); for( String TL : TList[0] ) { if( inData[1][i].equals( TL ) ) { break RIGHTTID; } } } } else { System.out.print( inData[0][i]+"を入力してください。\n>" ); inData[1][i] = getChars(); } } return inData[1]; } /** 入力された稼働状況をタブ区切りの文字列に変換し,日付を付加する * @param inData 入力された稼働状況 * @return 文字列 */ String listtoStr_t( String[] inData ) { StringBuffer buff = new StringBuffer(); for( String inD : inData ) buff.append( "\t"+inD ); //稼働状況データ SimpleDateFormat dateFormat = new SimpleDateFormat( "yyyyMMdd" ); String today = dateFormat.format( new Date() ); buff.append( "\t"+today+"\t"+today+"\t" ); //日付 return buff.toString(); } /** 取引先リストの項目を文字列に変換する * @param TList 取引先リストを表す文字列配列 * @return 文字列 */ String listtoStr_t( String[][] TList ) { StringBuffer buff = new StringBuffer(); for( int i = 0; i < TList[0].length; i++ ) { buff.append( TList[0][i]+" "+TList[1][i] ); buff.append( "\n" ); } buff.append( " [" ); //取引先IDリストの表示 for( String TL : TList[0] ) { buff.append( TL ); buff.append( "," ); } buff.deleteCharAt( buff.length()-1 ); //末尾の","を削除 buff.append( "]>" ); return buff.toString(); } /** 取引先リストをファイルから読み込む * @return 取引先リスト */ String[][] getTList() { String instr; ArrayList<String> a_ID = new ArrayList<String>(); ArrayList<String> a_Name = new ArrayList<String>(); try { BufferedReader br = new BufferedReader( new FileReader( "torihiki.txt" ) ); //取引先マスタを開く //取引先マスタから1レコードずつ読込み while( (instr = br.readLine()) != null ) { if( (instr.length()-1) == instr.lastIndexOf( '\t' ) ) { //削除日付なし String[] s = instr.split( "\t" ); a_ID.add( s[0] ); a_Name.add( s[1] ); } } br.close(); //取引先マスタを閉じる } catch( FileNotFoundException e ) { //取引先マスタがない } catch( IOException e ) { //取引先マスタへのアクセスエラー } String[][] TList = new String[2][a_ID.size()]; for( int i = 0; i < a_ID.size(); i++ ) { TList[0][i] = a_ID.get( i ); //取引先IDのセット TList[1][i] = a_Name.get( i ); //会社名のセット } return TList; } /** メッセージ番号に合わせてメッセージを選択し,標準入力からの入力を受け付ける * @param mID メッセージ番号を表す列挙型定数 * @return 入力文字列 */ String displayMessage( KI_MESS mID ) { String mess = ""; switch( mID ) { case GETID: //人材IDの取得 mess = "人材IDを入力してください。\n>"; break; default: //エラー } System.out.print( mess ); return getChars(); } }
オリジナルソースではRepositoryやConsoleなどの抽象型が抽出されていないため、分岐、ループ、配列、基本型のようなはるかに低水準の機能を使ってロジックが記述されています。そのため、プログラムで実現したいこと=外部仕様とのつながりがはるかに見えにくくなっています。両者を比較して、これでもオブジェクト指向の方が複雑で保守が大変と思われますか?単純にコードが数十パーセント短くなるとか、そういうレベルでの違いではありません。まさに、桁違いで単純化され、保守コストが下がっています。さらに、リファクタリングの結果抽出されたRepositoryやConsoleといったクラスは、人材管理という固有の機能にまったく依存しないため、別のアプリケーションでも再利用ができる点も見逃さないでください。
ポリモーフィズムの活用によるメインプログラムの単純化と拡張性の向上
オリジナルのメインプログラムではメニューの選択項目にしたがって、サブ機能にswitch文で分岐しています。
(Javaプログラミング能力認定試験1級問題より引用) /** 各機能の呼出し.各機能より制御が戻ったら,キー入力を受け付ける * @param contentsNO 機能コードを表す整数値 * @return 終了フラグ。「終了」の機能コードが渡されたときのみTrue */ boolean functionStart( int contentsNO ) { switch( contentsNO ) { case 0: // 人材検索 SInput sinput = new SInput(); sinput.startThis(); break; case 1: // 人材管理(追加) JInput jinput = new JInput(); jinput.startThis(); break; case 2: // 人材管理(更新) JUpdate jupdate = new JUpdate(); jupdate.startThis(); break; case 3: // 人材管理(削除) JDelete jdelete = new JDelete(); jdelete.startThis(); break; case 4: // 稼働状況管理(追加) KInput kinput = new KInput(); kinput.startThis(); break; case 5: // 稼働状況管理(削除) KDelete kdelete = new KDelete(); kdelete.startThis(); break; case 6: // 終了 return true; default: // 入力エラー return false; } if( contentsNO > 0 ) { //人材管理と稼働状況管理のみ System.out.print( "エンターキーを押すとメニューに戻ります。\n>" ); getChars(); } return false; }
この試験問題程度のプログラムではこれでも何とか理解できるレベルではありますが、今後機能が追加される都度、このswitch文も修正していかなくてはなりません。ここで注目すべきことは、呼び出しの対象となっている各サブ機能のエントリーポイントは全てstartThis()という共通のメソッドで始まっていることです。このような場合こそ、継承とポリモーフィズムを使うべき時です。Java言語で、メソッドをポリモーフィックに呼び出すためには、単に共通のインターフェースを各クラスに実装させればよいだけなので、この場合のリファクタリングは簡単です。
実際に、メインクラスの中でサブ機能を起動している部分は以下のように非常に簡単になります。
protected Map<String, Function> functionMap = new HashMap<String, Function>(); /** * 機能コードに該当する機能を呼び出す */ private void runFunction(String inputCode) { Function subFunction = functionMap.get(inputCode); if (subFunction == null) return; try { subFunction.run(); } catch (Exception ex) { // TODO 適切な例外処理 ex.printStackTrace(); } }
ポリモーフィズム(多態性)という用語が難しく聞こえるのですが、以上のように同一のメソッドを呼び出し可能な任意の型のオブジェクトを呼び出し元で区別せず一様に扱えるということに過ぎません。マップの中からコードに対応するサブ機能オブジェクトを取り出して単にrun()メソッドを起動しています。そのオブジェクトがどの機能であるかを区別する必要はまったくありません。これはちょうど筆箱の中にボールペンや鉛筆などさまざまな筆記用具が入っている場合に、そこから任意の筆記用具を取り出して文字を書くということと同じです。「A」という文字を書く際に通常は筆記用具の違いをわざわざ意識する必要はありません。そんなことを考えていたらノイローゼになってしまいそうです。*1このようにオブジェクト指向言語では日常無意識に行っている抽象化や単純化の考え方が取り入れられています。決して、研究者やプログラミングマニア専用の機能というわけではありません。
※試験問題のソースを一部掲載している部分については、著作権法第32条の引用にあたるため合法であると理解しています。引用 - Wikipedia
(その3につづく予定)
*1:厳密に言えば、筆圧など微妙な調整が必要なことは言うまでもありませんが。