Javaで単語学習アプリを作ろう

こんにちは。プログラミングを勉強していると、一刻も早く単語学習アプリを作りたくなりますよね。え、ならないんですか？？？

単語学習アプリを作ってみたい同志たちの願いを叶えるべく、今回はJavaで、フラッシュカード式の単語学習ができる機能を作ってみました。アプリというほど整った形ではありませんが、ひとまずコンソールで使えるレベルになりました。

例によって、正しく動きはするものの最適な書き方であるか自信がない部分もありますので、何かお気づきになりましたらご指摘いただければと思います。

今回扱う項目は以下です。

- tsvファイルを作り、ソースコードで内容を読み込む。
- 読み込んだ情報をもとに、ランダムで単語を表示させる。


前提条件

- IntelliJ IDEA： 2021.1.2 (Community Edition)
- JDK：16.0.1
- MacOS：Version 11.1

## 1.今回やることの整理

今回は以下のような要件を満たした機能を作ります。

- 起動すると、日→英か英→日どちらかの学習モードを選べる
- スタートすると無作為に選ばれた単語が表示される
- エンターを押すと、その単語に対応する訳語が表示される
- 続けて次の単語が表示され、同じ動作を延々と繰り返すことができる

アプリ作りの大まかな流れとしては、以下のイメージです。

1. 英単語とその日本語訳を羅列したtsvファイルを用意する
1. そのtsvファイルをコードで読み込み、データを２次元配列に格納する
1. 疑似乱数を用意して、その２次元配列の中身をランダムに表示させる

## 2.tsvファイルを用意する

まずは素材となるtsvファイルを作っていきます。Googleスプレッドシートを使うと簡単に作成することができるようですね。

以下の画像のように、英語と日本語のペアを記入したシートを用意します。

![Screen Shot 2021-08-14 at 12.45.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1676583/9d1cf533-a72b-e529-6cd7-ff26d30f7aa9.png)

整ったらファイル→ダウンロードから「タブ区切り」の値を選択すると、tsvファイルをダウンロードできます。

![Screen Shot 2021-08-14 at 12.46.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1676583/608e1aed-52b5-bda5-dfa5-c05d27644cb5.png)

ちなみにここでカンマ区切りのcsvを選択して、以降の「tsv」の記述を「csv」に変えることで同じように動作します。
今回の機能を作るにあたって、tsvとcsvを選択する際に大きな違いはありません。

csvだとカンマで値を区切るためデータの中にカンマが含まれていると厄介であるというデメリット、tsvだときちんと1つのタブで区切られているのか、スペースで区切られているのかぱっと見で見づらいというデメリットがあるかなと思います。

以下のように「fruitsWords.tsv」というファイル名で、Mainのjavaファイルと同じ階層に配置しておきます。

![Screen Shot 2021-08-15 at 18.40.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1676583/1819dcc5-4d8d-0e03-6ea7-0031e00e451c.png)

## 3.tsvファイルの内容を２次元配列に格納する

それではtsvファイルの内容を一行ずつタブで区切って２次元配列に格納していくコードを書きましょう。この部分は[こちらの記事](https://www.fenet.jp/java/column/java_tips/3760/)の「2次元配列にデータを格納する」の内容を大いに参考にさせていただいております。
Mainクラスに以下の記述を加えます。

```Main.java
public class Main {

    public static void main(String[] args) {
        BufferedReader br = null; //テキストファイルを読み込むためのクラスを呼び出しておく
        String file_name = "src/com/company/fruitsWords.tsv"; //用意したtsvファイルのパスをString型に保存する

        final int MAX_DATA_NUMS = 50; //データの最大個数を決めておく必要がある（tsvファイルのデータ数に応じて変更する）

        String[][] data = new String[MAX_DATA_NUMS][]; //２次元配列を宣言する

        try { //以下配列にデータを格納する
            File file = new File(file_name); //ファイルを読み込むためのクラス
            br = new BufferedReader(new FileReader(file));  //FileReaderはテキストファイルを読むためのクラス

            int index = 0; //行番号を示す
            String line; //読み込んでいる行を都度格納する変数
            while ((line = br.readLine()) != null) { //読み込むべき次の行がなくなったらループを抜ける

                data[index] = line.split("\t"); //タブで分割し２次元配列に格納する
                index++; //次の行に移るためにindexを1増やす

            }
        } catch (Exception e) { //ファイル読み込みでエラーが起きたら通る部分

            System.out.println(e.getMessage()); //エラーメッセージを表示する

        } finally { //必ず通る部分（特に処理はなし）

        }
        //ここにモード選択、単語表示の処理を記述
    }
}
```

各行にコメントを入れていますが、まずファイルを一行ずつ読み込むために必要なBufferedReaderクラスをインスタンス化し、読み込むtsvファイルのパスを変数に格納しておきます。２次元配列は宣言時に最大個数を宣言する必要がありますので、ひとまずMAX_DATA_NUMSに50という値を格納します。

try文の中ではtsvファイルの各行を読み、実際に配列にデータを格納しています。ファイルを扱うためのFileクラスやテキストデータを扱うためのFileReaderクラスをインスタンス化しておきます。

while文のループ条件になっている「line = br.readLine()) != null」についてですが、BufferedReaderクラスのreadLineメソッドは一行ずつファイルを読み込むメソッドですので、読むべき行がなくなった時点でループを抜けることになります。

catch文の中では、ファイル読み込みと配列へのデータ格納でエラーが発生した時にエラーメッセージを出力させる処理が書かれています。
finally文はtry、catch文いずれかの処理が終了した時必ず通る部分ですが、今回は特に必要な処理がないため空白にしておきます。

これで２次元配列に、以下の通り英単語、日本語訳の順で値が入っている状態になりました。

data{{apple, りんご}, {banana, バナナ}, {carrot, にんじん}, …}

今気づきましたが、にんじんは果物ではないですね。

## 4.モードを選択し単語を表示させる

前節で記述したfinally部分の下に続けて、以下の記述を追加します。

```Main.java
System.out.println("どのモードで学習するか選んでね！！！\na:ランダム日→英\nb:ランダム英→日");
Scanner modeScanner = new Scanner(System.in); //入力を受け取るScannerクラス
String modeInput = modeScanner.nextLine(); //入力を受け取った文字列をmodeInputという変数に格納する

if (modeInput.equals("a")) { //「a」の入力を受け取った場合
    while (true) { //無限ループを構成する

        Random random = new Random(); //疑似乱数を生成するクラス
        int randomValue = random.nextInt(10); //0以上10未満の乱数
        Scanner enterInput = new Scanner(System.in); //enterの入力を受け取る
        System.out.println("Q: " + data[randomValue][1]); //問題となる値を表示する
        enterInput.nextLine(); //enterの入力待ち
        System.out.println("A: " + data[randomValue][0] + "\n\n"); //答えとなる値を表示する

    }
} else if (modeInput.equals("b")) {
    while (true) {

        Random random = new Random();
        int randomValue = random.nextInt(10);
        Scanner enterInput = new Scanner(System.in);
        System.out.println("Q: " + data[randomValue][0]); //aの分岐と逆の表示順にする
        enterInput.nextLine();
        System.out.println("A: " + data[randomValue][1] + "\n\n");

    }
} else { //「a」「b」以外の入力を受け取った時

    System.out.println("やる気ないんですね。");

}

```

実行時に「a」の入力を受け取ったら日本語の単語を表示させて問題を出すモード、「b」の入力を受け取ったら英単語を表示させて問題を出すモードになるようにします。
最初の三行ではモードの説明を表示したのち、Scannerというクラスをインスタンス化し、「a」か「b」かの入力を受け取ります。

その次のif文で「a」モードの場合、「b」モードの場合、それ以外の入力を受け取った場合に場合分けします。それ以外の入力を受け取った場合はメッセージを表示させて終了する処理にしました。

if文の中ではまず「while(true)」という無限ループが用意されています。これによってエンターキーを押す限り無限に英単語暗記ゲームができるようになります。

while文の中ではRandomというクラスを用いて疑似乱数を生成します。random.nextInt(10)のカッコの中の値をtsvファイルの単語ペア数にします。

その次に再びScannerクラスで入力を受け取れるようにします。enterInput.nextLine()はエンターキーの入力を受け取ったら次に進むよという記述になります。

単語を表示させる部分ではdata[randomValue][1])のように記述しています。randomValue行目の1列目の単語を表示させるということですが、randomValueは先ほど生成した疑似乱数ですのでランダムな値が指定されます。今回のtsvファイルは英単語、日本語訳の順でデータを入れてあるので、0列目が英単語、1列目が日本語訳となります。

ちなみにwhile文が実行されるたびに疑似乱数の値が変わるため、毎回違う単語が表示されるようになります。

「b」の入力を受け取った場合にもwhile文の中で同じような記述をしていますが、最初にdata[randomValue][0]を表示させることにより、英単語が先に表示されるようになります。

どのような動きになるか、実行してコンソールで確認してみましょう。

まずモードの選択肢が表示され、「a」を入力してエンターを押すと早速問題が表示されました。

![Screen Shot 2021-08-16 at 10.22.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1676583/5fa63a46-3064-cc6e-34e1-6a8ec178d43b.png)

エンターキーを入力するたびに解答と次の問題が表示されます。表示される単語のペアも毎回違うものになっていますね。

ちなみに解答と次の問題との間隔を調整するため、"\n\n"の記述を追加して改行を2つ入れています。

![Screen Shot 2021-08-16 at 10.22.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1676583/9f6ba314-3d58-4ad4-6aa3-ff0446ac56f6.png)

「b」のモードを選択すると、先に英単語が表示されます。

![スクリーンショット 2021-08-18 8.58.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1676583/ee10c19d-8d06-70fd-f3e7-f36ff71a6055.png)

「a」「b」以外の予期しない入力を受け取った場合も、怒られて無事終了していますね。

![スクリーンショット 2021-08-18 9.00.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1676583/9eb95454-a939-7ff7-d82c-f1afa609ee54.png)

## 5.2021年8月24日追記
記事の公開後、会社の先輩社員からフィードバックをいただき、プラスアルファのテクニックを教えていただきました。ありがとうございます！
ここまで書いてきたコードを見ると、2つのモードそれぞれでほとんど全く同じ処理を書いてしまっています。このような全く同じ処理はできるだけ避けるのがよいとのことです。教えていただいた一つの手が以下です。第３項で書いたfinally部分の下に続けて、第４項の記述の代わりに以下を追加します。

```Main.java
System.out.println("どのモードで学習するか選んでね！！！\na:ランダム日→英\nb:ランダム英→日");
Scanner modeScanner = new Scanner(System.in); //入力を受け取るScannerクラス
String modeInput = modeScanner.nextLine(); //入力を受け取った文字列をmodeInputという変数に格納する

int question = 0; //変数を宣言
int answer = 0; //変数を宣言

if(modeInput.equals("a")){ //モードaの場合

    question = 1;

} else if(modeInput.equals("b")){ //モードbの場合

    answer = 1;

} else { //「a」「b」以外の入力を受け取った時

    System.out.println("やる気ないんですね。");
    System.exit(0); //処理を強制終了させる

}

while (true) { //無限ループを構成する

    Random random = new Random(); //疑似乱数を生成するクラス
    int randomValue = random.nextInt(10); //0以上10未満の乱数
    Scanner enterInput = new Scanner(System.in); //enterの入力を受け取る
    System.out.println("Q: " + data[randomValue][question]); //問題となる値を表示する
    enterInput.nextLine(); //enterの入力待ち
    System.out.println("A: " + data[randomValue][answer] + "\n\n"); //答えとなる値を表示する

}
```

ユーザから「a」か「b」かの入力を受け取るところまでは同じです。その後questionとanswerという変数を宣言し、「a」モードの場合は、questionが1、answerが0になるようにします。「b」モードの場合は逆に、questionが0、answerが1になるようにします。
この二つの変数はwhile文の中で使っています。「a」モードの場合は、問題を表示させるときにdata[randomValue][1]となるわけなので、先に日本語が表示されます。「b」モードの場合はもうお分かりですね、逆にdata[randomValue][0]となるので英語が先に表示されます。なるほど...という感じです。

「a」「b」以外の予期せぬ入力を受け取ったところではSystem.exit(0);という記述を入れています。これは処理を強制的に終わらせる便利な記述です。

ちなみに私は最初if文のところで以下のように書いてしまっていました。

```java
if(modeInput == "a"){
```

左辺と右辺が正しいかを判断するときに、最初に思い浮かべる演算子は確かに「==」なのですが、String型の変数では「==」は使わないようですね。今回は代わりにequalsというメソッドを使いました。意味は「==」演算子と同じようです。
String型でなぜ「==」を使わないかについては、[こちらの記事](https://www.sejuku.net/blog/14621)を参考にしました。

## 6.最後に

以上、ごくシンプルな単語学習機能を開発しました。無限に単語を暗記していきましょう。今後は、登録されている単語のペア数だけモレなく被りなく問題が表示されるようにする、モード選択のバリエーションを増やす、辞書機能を追加するなど、さらに単語アプリらしい充実機能を加えていきたいですね。


参考にした記事：

- [JavaでのCSVファイル読み込み方法とは？](https://www.fenet.jp/java/column/java_tips/3760)
- [「タブ区切りは CSV じゃなくて TSV だろ」問題がついに解決した](https://www.msng.info/archives/2015/12/tsv-or-csv.php)
- [【Java】FileReader、BufferedReaderでテキストファイルを読み込む](https://www.sejuku.net/blog/20924)
- [【Java入門】2次元(多次元)配列の使い方(宣言、初期化)](https://www.sejuku.net/blog/39718)
- [【Java入門】文字列(String)を比較する方法(「==」と「equals」)](https://www.sejuku.net/blog/14621)