LoginSignup
12

More than 5 years have passed since last update.

JavaっぽいJavaのコードを(ある程度)ScalaっぽいScalaのコードに書き換える

Posted at

はじめに

この記事は
「Scalaの言語仕様とか多少見てみたけど具体的にどんな感じで書けばいいのかいまいち分からない」
と感じている人に、Scalaを始める際の参考にしてもらえたら、と思って書いています。
なので、既にScalaをバリバリ使いこなしている人にとっては何の参考にもならないと思いますのでご了承ください。

なお、以下で登場するコードは次の環境で確認しております。
OS : Ubuntu 14.04
JDK : OpenJDK 1.8.0_45
Scala : 2.11.7

JavaのコードをScalaのコードに書き換える

それでは、簡単なバッチ処理的なコードを題材に話を進めてまいりたいと思います。
以下のような処理を行うコードを考えます。

  1. CSVからデータを読み込みます。CSVには "メールアドレス", "時間(s)", "金額(円)" という形式のデータが(正常であれば)入っているものとします。 (なお、話を簡単にするために厳密なCSVとしてのチェックは考えないものとします)
  2. 正常なデータの内、時間が18000以上または金額が5000以上のデータは金額を500減らします。
  3. 2.の処理後のデータの内、時間が3600以上かつ金額が1000以上のデータだけを抽出してデータベースに登録します。 (なお、話を簡単にするためにデータベース登録処理は仮の処理で代用します)

これをJavaで書くと以下のような感じになるかと思います。

CSVData.java
public class CSVData {
  private String mail;
  private long time;
  private int money;

  @Override
  public String toString() {
    return String.format("CSVData(\"%s\", %d, %d)", mail, time, money);
  }
  // 以下 getter / setter
  ...
}
BatchMain.java
import java.io.*;
import java.util.List;

public class BatchMain {
  public static void main(String[] args) {
    if (args.length < 1) {
      throw new IllegalArgumentException("CSVファイルを指定してください");
    }
    File file = new File(args[0]);
    if (!file.exists() || !file.isFile()) {
      throw new IllegalArgumentException("CSVファイルを指定してください");
    }
    try {
      List<CSVData> list = LoadCSV.load(file);
      DataCheck.discountHeavyUser(list);
      list = DataFilter.filterActiveUser(list);
      RegistDatabase.registActiveUsers(list);
    } catch (IOException e) {
      System.err.println("CSVファイルの読み込みに失敗しました");
      e.printStackTrace();
    }
  }
}
LoadCSV.java
import java.io.*;
import java.nio.charset.Charset;
import java.util.*;

public class LoadCSV {
  public static List<CSVData> load(File file) throws IOException {
    BufferedReader br = null;
    try {
      br = new BufferedReader(
             new InputStreamReader(
              new FileInputStream(file), Charset.forName("UTF-8")));
      List<CSVData> list = new ArrayList<>();
      while(true) {
        String str = br.readLine();
        if (str == null) {
          break;
        }
        CSVData data = checkData(str);
        if (data != null) {
          list.add(data);
        }
      }
      return list;
    } finally {
      if (br != null) {
        br.close();
      }
    }
  }
  private static CSVData checkData(String str) {
    String[] strs = str.split(",");
    if (strs.length != 3) {
      return null;
    }
    long time = 0L;
    try {
      time = Long.parseLong(strs[1]);
    } catch (NumberFormatException e) {
      return null;
    }
    int money = 0;
    try {
      money = Integer.parseInt(strs[2]);
    } catch (NumberFormatException e) {
      return null;
    }
    CSVData data = new CSVData();
    data.setMail(strs[0]);
    data.setTime(time);
    data.setMoney(money);
    return data;
  }
}
DataCheck.java
import java.util.List;

public class DataCheck {
  public static void discountHeavyUser(List<CSVData> list) {
    for (CSVData data : list) {
      long time = data.getTime();
      int money = data.getMoney();
      if (time >= 18000L || money >= 5000) {
        data.setMoney(money - 500);
      }
    }
  }
}
DataFilter.java
import java.util.*;

public class DataFilter {
  public static List<CSVData> filterActiveUser(List<CSVData> baseList) {
    List<CSVData> list = new ArrayList<>();
    for (CSVData data : baseList) {
      long time = data.getTime();
      int money = data.getMoney();
      if (time >= 3600 && money >= 1000) {
        list.add(data);
      }
    }
    return list;
  }
}
RegistDatabase.java
import java.util.List;

public class RegistDatabase {
  public static void registActiveUsers(List<CSVData> list) {
    // 仮の処理
    for (CSVData data : list) {
      System.out.println(data);
    }
  }
}

これを実行すると以下のような結果になります。

myuser@myuser$ cat /home/myuser/sample.csv
hoge@piyo.com,18000,5000
foo@bar.com,18000,1000

myuser@myuser$ java BatchMain /home/myuser/sample.csv
CSVData("hoge@piyo.com", 18000, 4500)

このコードをScalaに書き換えていきます。

1. とりあえずScalaでコンパイルが通るコードに書き換える

まずはScalaでコンパイルが通るコードに書き換えるところから始めましょう。
ファイルの拡張子を.scalaに変更し、以下のような感じで書き換えます。
(Intellij だと自動変換できるらしいですが、とりあえず手動で書き換えていくことにします)
なお、この書き換えは正確ではないのでご注意ください。
(例えば、拡張for文を正確に書き換えるとwhileを使ったループになるかと思いますが、対応が分かりにくくなりますのでfor式で書ける方法を利用しています)

CSVData.scala
class CSVData {
  private var mail: String = _;
  private var time: Long = _;
  private var money: Int = _;

  override def toString():String = {
    return "CSVData(\"%s\", %d, %d)".format(mail, time, money); // LongやIntをjava.lang.Objectとして扱う方法が分かりませんでした…
  }
  // 以下 getter / setter
  ...
}
BatchMain.scala
import java.io._;
import java.util.List;

object BatchMain {
  def main(args: Array[String]):Unit = {
    if (args.length < 1) {
      throw new IllegalArgumentException("CSVファイルを指定してください");
    }
    var file:File = new File(args(0));
    if (!file.exists() || !file.isFile()) {
      throw new IllegalArgumentException("CSVファイルを指定してください");
    }
    try {
      var list:List[CSVData] = LoadCSV.load(file);
      DataCheck.discountHeavyUser(list);
      list = DataFilter.filterActiveUser(list);
      RegistDatabase.registActiveUsers(list);
    } catch {
      case e: IOException =>
        System.err.println("CSVファイルの読み込みに失敗しました");
        e.printStackTrace();
    }
  }
}
LoadCSV.scala
import java.io._;
import java.nio.charset.Charset;
import java.util._;
import java.lang.Long;

object LoadCSV {
  def load(file: File):List[CSVData] = {
    var br: BufferedReader = null;
    try {
      br = new BufferedReader(
             new InputStreamReader(
               new FileInputStream(file), Charset.forName("UTF-8")));
      var list: List[CSVData] = new ArrayList();
      var cont:Boolean = true;
      while(cont) {
        var str:String = br.readLine();
        if (str == null) {
          cont = false;
        } else {
          var data:CSVData = checkData(str);
          if (data != null) {
            list.add(data);
          }
        }
      }
      return list;
    } finally {
      if (br != null) {
        br.close();
      }
    }
  }
  private def checkData(str: String):CSVData = {
    var strs:Array[String] = str.split(",");
    if (strs.length != 3) {
      return null;
    }
    var time:Long = 0L;
    try {
      time = Long.parseLong(strs(1));
    } catch {
      case e:NumberFormatException =>
        return null;
    }
    var money:Int = 0;
    try {
      money = Integer.parseInt(strs(2));
    } catch {
      case e:NumberFormatException =>
        null;
    }
    var data:CSVData = new CSVData();
    data.setMail(strs(0));
    data.setTime(time);
    data.setMoney(money);
    return data;
  }
}
DataCheck.scala
import java.util.List;
import scala.collection.JavaConversions._ // Javaのコレクションをfor式で扱うのに必要

object DataCheck {
  def discountHeavyUser(list:List[CSVData]):Unit = {
    for (data:CSVData <- list) {
      var time:Long = data.getTime();
      var money:Int = data.getMoney();
      if (time >= 18000L || money >= 5000) {
        data.setMoney(money - 500);
      }
    }
  }
}
DataFilter.scala
import java.util._;
import scala.collection.JavaConversions._ // Javaのコレクションをfor式で扱うのに必要

object DataFilter {
  def filterActiveUser(baseList:List[CSVData]):List[CSVData] = {
    var list:List[CSVData] = new ArrayList();
    for (data:CSVData <- baseList) {
      var time:Long = data.getTime();
      var money:Int = data.getMoney();
      if (time >= 3600 && money >= 1000) {
        list.add(data);
      }
    }
    return list;
  }
}
RegistDatabase.scala
import java.util.List;
import scala.collection.JavaConversions._ // Javaのコレクションをfor式で扱うのに必要

object RegistDatabase {
  def registActiveUsers(list: List[CSVData]) = {
    // 仮の処理
    for (data:CSVData <- list) {
      System.out.println(data);
    }
  }
}

いろいろと変わっていますが、主なところを挙げていくと

  • importの * が _ になっている
  • staticメソッドのstaticが無くなり、所属する場所がclassからobjectに変わっている
  • メソッドの記述が [アクセス修飾子][戻り値の型][メソッド名]([引数]){} から [アクセス修飾子] def [メソッド名]([引数]):[戻り値の型]={} に変わっている (なお、Scalaではデフォルトの可視性がJavaにおけるpublicとなっており、かつ明示的にpublicと記述することができないため、publicだった部分にはアクセス修飾子が書かれていない)
  • 変数の宣言が [アクセス修飾子][型名][変数名] から [アクセス修飾子] var [変数名]:[型名] に変わっている
  • 配列がArrayになっており、その要素へのアクセスが変数名[インデックス]から変数名(インデックス)になっている
  • catch句が catch([例外型名][引数名]){} から catch { case [引数名]:[例外型名] => } に変わっている
  • while(true)がwhile(cont)となっており、breakの部分がcont=falseとなっている。(これは、Scalaのwhile文でbreakが使えないことへの対応である)

等の違いがあります。
メソッドや変数の記述方法を見ると「むしろ記述量が増えているのでは?」と思うかもしれません。
確かにそうなのですが、これはあくまでJavaのコードと対比するためにわざと冗長に記述していることによるものです。
実際には変数の型やメソッドの戻り値型は大抵省略可能なため、むしろJavaより簡潔に記述することが可能です。
また、「while文でbreakが使えないって大丈夫なの?」と思われるかもしれませんが、そもそもScalaではwhile文を使うことが殆どないため問題になることは無いかと思います。

2. 省略できる部分を省略する

というわけで省略可能な部分を省略していきます。

変数やメソッドの戻り値の型を省略する

変数の型やメソッドの戻り値型の内、コンパイラが推測可能な部分は省略可能です。
とりあえず省略し、コンパイルエラーが出たら元に戻す、くらいに考えておいても(基本的には)問題ありません。
(ただし、Unit以外を返すpublicなメソッドの戻り値は省略可能でも省略しないほうが良いかもしれません。UnitとはJavaでいうvoidのことだと思っておけばとりあえず問題無いかと思います。)

セミコロンを省略する

Scalaでは基本、文の終わりにセミコロンを付ける必要がありません。これも消していきましょう。

returnを省略する

Scalaではメソッドの最後の文の値がそのメソッドの戻り値になるので、returnを記述する必要はありません。これも消していきましょう。

中括弧を省略する

Scalaでは中括弧を付ける部分において、文が1つしか無い場合は中括弧を省略できることが多いです。
「Javaでも省略できるのでは?」と思われるかもしれませんが、Scalaではメソッドの中括弧等Javaでは省略できない部分も省略可能となっております。
「省略できるけど省略すべきではないのでは?」と思われるかもしれませんが、Scalaでは基本的に不要なものをなるべく省略するのが一般的なので省略したほうが良いかと思います。

メソッド呼び出しの()を省略する

Scalaでは、メソッド呼び出しの()を省略可能なことが多いです。
いつでも省略したほうが良いわけではない(特に副作用を持つメソッドは省略すべきではない)ですが、省略しても問題ないところでは省略したほうがスッキリするかと思います。

特殊なメソッド名の省略・各種糖衣構文等

現時点ではまだ出てきていない(わけでもない)ですが、以下のような省略が可能です(これで全てではありません)。

  • applyという名前のメソッドは基本的に省略可能
  • 引数を一つ取るメソッドの呼び出しは.と()が省略可能 (つまり、a.b(c)a b c と記述できる)

(わけでもない)というのは、BatchMainでコマンドライン引数の入っているArrayから値を取り出すときにargs(0)のように記述している部分が、実はこの省略法を用いているからです。args(0)を省略せずに書くとargs.apply(0)となります。
また、DataCheckでmoney - 500となっている部分も、省略せずに書くとmoney.-(500)となります。

毎回コードを載せると長くなりすぎますので今回はコードを省略いたします。

3. Javaっぽいところを改善していく

現時点でのコードは「JavaっぽいScalaのコード」としか言いようがないので、少しずつ改善していきます。

可変性や副作用を(可能な限り)取り除く

現時点でのコードがScalaっぽくない理由の一つは「再代入(破壊的代入)が行われている」点です。
再代入を許すコードは変数の実際の値が何か分かりにくく、NullPointerExceptionを初めとするバグの温床となる傾向があるため、Scalaではなるべく再代入を行わないように書くのが一般的です。
Scalaでは変数をvar, 定数をvalで定義するので、varを可能な限りvalで書き換えることにします。

また、メソッドについても、そのメソッドが値を返す以外の影響を外部に与えるか、引数の値以外の影響を受ける(=副作用を持っている)とバグの温床となりがちなので、なるべく副作用の無いように書くのが一般的です。
例えば、DataCheck.discountHeavyUserは引数として与えられたデータの内容を変更していますが、これを「変更した内容を持つ新しいデータを生成する」ように書き換えましょう。

データクラスはcase classで定義する

また、CSVDataクラス自体も不変オブジェクトに変更して、データの書き換えができないようにしましょう。
ところで、現時点でのデータクラスの定義はあまりにも冗長だとは思いませんか?。
実は、Scalaではcase classというものを利用することでデータクラスを非常に簡潔に記述することが可能となります。
しかもcase classを使うと

  • 自動的にequals/hashcode/toString 等のメソッドが「いい感じに」定義される
  • インスタンス生成時のnewが省略できる (本当はnewを省略しているわけではなく、コンパニオンオブジェクトのapplyが自動で「いい感じに」定義されるのでそれを使っている)

等と様々な恩恵を受けることができます。

nullを排除する

突然ですが、皆さんはnullが好きですか?
え、嫌いですか?。そうですね、私も嫌いです。
nullは百害あって一利ないのでとにかく排除しましょう。
Scalaでは、存在しないかもしれない値のラッパーとしてOptionというものが用意されていますので、これを利用しましょう。

例外を投げない

「いきなり何を言い出すんだ」と思われるかもしれませんが、落ち着いて聞いてください。
そもそも例外を投げる必要があるのでしょうか?
必要がある場合もあるかもしれませんが、大抵は不要でしょう。なぜなら、呼び出し元は基本的に例外をキャッチして別の処理を行うからです。
要するに、呼び出し元は呼び出した処理が失敗したからといってプログラム自体を強制終了させるつもりは(基本的に)ない、ということです。
であれば、例外を投げるのではなくメソッドの戻り値を「成功した時は成功した結果、失敗した時は失敗した理由」にすれば良いとは思いませんか?。
「スクリプト言語とかであればともかく、静的型付けを行う言語で状況によって戻り値の型を変えられるはずないだろう」ですか?
無論、型を変えたりはしません。最初から「成功した時の結果か失敗した時の理由を持つ型」を戻り値の型に指定することで対応します。
Scalaでは、こういうときのラッパーとしてEither(あるいはscalazの\/)を利用することができますので、これを利用しましょう。

一つのメソッドが行う仕事を極小化する

ここで今一度コードを見直して見てください。「メソッド一つの仕事量が過剰だ」と感じませんか?。
例えばDataCheck.discountHeavyUserを見てください。このメソッドは

  • list内のデータについて繰り返す
  • 時間が18000以上または金額が5000以上であるかをチェックする
  • 金額を500減らしたデータを生成する
  • 新しいリストに生成したデータを設定する

という仕事をしています。過剰ですね?
仕事は少なければ少ないほど理解しやすく、変更しやすく、テストしやすく、再利用しやすく、バグの少ないコードになります。
ですので一つのメソッドの仕事を減らしていきましょう。

ここまでの変更を反映したコードを以下に記載します。

CSVData.scala
case class CSVData(mail: String, time: Long, money: Int)
BatchMain.scala
import java.io._
import java.util.List

object BatchMain {
  def main(args: Array[String]) = {
    if (args.length < 1) {
      throw new IllegalArgumentException("CSVファイルを指定してください")
    }
    getFile(args(0)) match {
      case Some(file) => {
        LoadCSV.load(file) match {
          case Right(list) => {
            val tempList1 = DataCheck.discountHeavyUser(list)
            val tempList2 = DataFilter.filterActiveUser(tempList1)
            RegistDatabase.registActiveUsers(tempList2)
          }
          case Left(e) => {
            System.err.println("CSVファイルの読み込みに失敗しました")
            throw e
          }
        }
      }
      case None => throw new IllegalArgumentException("CSVファイルを指定してください")
    }
  }
  private def getFile(path: String) = {
    val file = new File(path)
    if (!file.exists || !file.isFile) None else Some(file) 
  }
}
LoadCSV.scala
import java.io._
import java.nio.charset.Charset
import java.util._
import java.lang.Long

object LoadCSV {
  def load(file: File):Either[IOException, List[CSVData]] = {
    var oBr: Option[BufferedReader] = None
    try {
      val br = new BufferedReader(
                 new InputStreamReader(
                   new FileInputStream(file), Charset.forName("UTF-8")))
      oBr = Some(br)
      Right(loadList(br))
    } catch {
      case e:IOException => Left(e)
    } finally {
      oBr match {
        case Some(br) => br.close()
        case None => ()
      }
    }
  }
  private def loadList(br:BufferedReader):List[CSVData] = {
    val list = new ArrayList[CSVData]
    var cont = true
    while(cont) {
      val str = br.readLine()
      if (str == null) {
        cont = false
      } else {
        checkData(str) match {
          case Some(csvData) => list.add(csvData)
          case None => false
        }
      }
    }
    list
  }
  private def checkData(str: String) = {
    split(str) match {
      case Some((mail, sTime, sMoney)) => parseLong(sTime) match {
        case Some(time) => parseInt(sMoney) match {
          case Some(money) => Some(CSVData(mail, time, money))
          case None => None
        }
        case None => None
      }
      case None => None
    }
  }
  private def split(str: String) = {
    val strs = str.split(",")
    if (strs.length != 3) None else Some((strs(0), strs(1), strs(2)))
  }
  private def parseLong(str: String) =
    try {
      Some(Long.parseLong(str))
    } catch {
      case _:NumberFormatException => None
    }
  private def parseInt(str: String) =
    try {
      Some(Integer.parseInt(str))
    } catch {
      case _:NumberFormatException => None
    }
}
DataCheck.scala
import java.util._
import scala.collection.JavaConversions._

object DataCheck {
  def discountHeavyUser(list:List[CSVData]):List[CSVData] = {
    val newList:List[CSVData] = new ArrayList
    for (data <- list) {
      newList.add(discount(data))
    }
    newList
  }

  private def discount(data: CSVData) = if (isDiscount(data.time, data.money)) data.copy(money = data.money - 500) else data

  private def isDiscount(time: Long, money: Int) = time >= 18000L || money >= 5000
}
DataFilter.scala
import java.util._
import scala.collection.JavaConversions._

object DataFilter {
  def filterActiveUser(baseList:List[CSVData]):List[CSVData] = {
    val list:List[CSVData] = new ArrayList
    for (data <- baseList) filter(data) match {
      case Some(csvData) => list.add(csvData)
      case None => false
    }
    list
  }
  private def filter(data: CSVData) = if (isActive(data.time, data.money)) Some(data) else None

  private def isActive(time: Long, money: Int) = time >= 3600 && money >= 1000
}
RegistDatabase.scala
import java.util.List
import scala.collection.JavaConversions._

object RegistDatabase {
  def registActiveUsers(list: List[CSVData]) = for (data <- list) println(data) // 仮の処理
}

いくつかまだ説明していなかった要素が入っていますので説明いたします。

if / try
if / try 自体はJavaにもありますし、Javaと大体同じですが、1つだけ大きな違いがあります。
それは、Scalaのif / tryは値を返すということです。なので関数の最後の文をそのままif-elseやtry-catchにして簡潔に記述することが可能となっています。
Some / None
先程「値が存在しないかもしれないもののラッパーとしてOptionを使う」という話をしましたが、そのOptionの具体的な中身がSomeまたはNoneです。Someが「値が存在する」、Noneが「値が存在しない」ことを表します。
Left / Right
先程「成功した時の結果か失敗した時の理由を持つもののラッパーとしてEither(かscalaz.\/)を使う」という話をしましたが、そのEitherの具体的な中身がLeftまたはRightです。言うまでもないですが、Leftが「左側の型の値」、Rightが「右側の型の値」を表します。
なお、Eitherでは左側と右側に優劣を定義していませんが、scalaz.\/では右側が優先と定義されています。(右(Right)と正しい(Right)を掛けているようです)
[値] match { case [パターン] => ... }
[値]に指定されたものがどのcaseに該当するかによって処理を振り分ける構文(パターンマッチ)です。Javaのswitch文と比較すると

  • 高度なマッチング条件の指定ができる
  • マッチした値の中身の値をそのまま定数に束縛できる
  • マッチしなかった場合はMatchErrorが発生する(コンパイル時にMatchしない可能性があると警告してくれる)

等の利点があります。

4. (ある程度)Scalaっぽくしていく

先程のコードは当初に比べれば大分マシになった気がしますが、まだアレなところも多いです。次はそれらの点を見ていきましょう。

考え方を変える

ここからは発想を変えていく必要があります。
LoadCSV.checkDataを見てください。match式が3つも入れ子になって分かりにくいとは思いませんか。
こうなっている原因はJava的な発想にあります。
Javaで何かに入っている値を使って何かをする場合、どうしますか。通常は、先程のコードように「値を取り出して(値をチェックして)使う」のではないでしょうか。しかし、そうするとこんな風にネストがどんどん深くなってしまいます。
とはいえ成功時の処理はまだギリギリ及第点かもしれません。完全に赤点なのは失敗時の処理の方です。
case None => Noneというのはいくら何でも、としか思えないですね。しかも、1回だけでも「うわぁ」という感じなのに、まさかの3回連続です。
考え方を変えましょう。「値を取り出して処理する」のではなく、「処理に値を適用してもらう」と考えてみましょう。適用するかどうかは値を保持している側がよろしくやってくれるはずです。
そんなときに使える便利な関数がmap / flatMapです。これについては詳細に説明すると長くなるので省略させていただきますが、先程のmatch式の入れ子の部分にmap / flatMapを導入すると以下のように記述することが可能となります。

LoadCSV(抜粋)
  private def checkData(str: String) = 
    split(str) flatMap { case (mail, sTime, sMoney) =>
      parseLong(sTime) flatMap { time =>
        parseInt(sMoney) map { money =>
          CSVData(mail, time, money)
      }
    }
  }

失敗時の処理が綺麗さっぱり無くなってスッキリしましたね。
え、「入れ子自体は変わってない」ですか?
まあこれ自体はどうしようもないのですが、ScalaではflatMapとmapが連なっているときに分かりやすく記述する方法がありますので、それを使って書き直しましょう。

LoadCSV(抜粋)
  private def checkData(str: String) = 
    for {
      (mail, sTime, sMoney) <- split(str)
      time <- parseLong(sTime)
      money <- parseInt(sMoney)
    } yield CSVData(mail, time, money)

「for? ループのはずじゃ…」ですか? 実はScalaのforはループではなく、map, flatMap(, foreach, withFilter等)の糖衣構文なのです。
どうでしょう。これなら見た目にも分かりやすいのではないでしょうか。

不変コレクションを使う

DataCheck.discountHeavyUserを見てみましょう。何か残念な匂いが漂ってきませんか?。
具体的に言うとnewList.add(~)の辺りでしょうか。
Javaのコレクションは大抵可変コレクションです。先程「可変性を取り除こう」という話をしたのに、ここで思いっきり可変リストを使っていては意味がありませんね。
Scalaにも可変コレクションはありますが、大抵使われるのは不変コレクションです。不変コレクションに変更しましょう。
「新しい値を不変リストに入れる方法は?」ですか? いいえ、ここは先程と同様考え方を変えましょう。
不変コレクションにもmap / flatMapがありますので、きっとリストの中身の値に処理を適用してくれることでしょう。

副作用を最小限に

LoadCSVのload辺りを見てみましょう。何と言いますか、もうちょっとこう、何とかならないものでしょうか。「再代入はなるべく使わない」「whileは殆ど使わない」と言っておきながら思いっきり使用しているのはいかがなものか、と言いますか。

再代入の方から見ていきましょう。再代入が必要となっている最大の理由は「try内で生成する必要があるBufferedReaderをfinallyでも参照する必要がある」ことにあります。
これ自体は変えられませんが、この「finallyで参照する」部分を別のメソッドに追い出せれば再代入が不要になるかもしれません。
何が言いたいかと言いますと、「BufferedReaderを受け取り、tryで何かしてfinallyでcloseを行う」メソッドを作れば解決できるのではないか、ということです。
「何か」って何?と思うかもしれませんが、要するに先程のmap / flatMapと同じです。「何か」は外側から与え、それを適用するかどうかは受け取った側が決めるようにすれば良いのです。
これを実現するコードは以下のような感じになるかと思います。

LoadCSV(抜粋)
  def load(file: File):Either[IOException, List[CSVData]] =
    try {
      val br = new BufferedReader(
                 new InputStreamReader(
                   new FileInputStream(file), Charset.forName("UTF-8")))
      autoCloser(br) { cbr =>
        Right(loadList(cbr))
      }
    } catch {
      case e:IOException => Left(e)
    }
  private def autoCloser[A](br: BufferedReader)(f: BufferedReader => A) =
    try {
      f(br)
    } finally {
      br.close()
    }

autoCloserの2番目の引数は「BufferedReaderを受け取ってAを返す関数」です。これでautoCloserは「何か」を気にすることなく「tryで何かしてfinallyでcloseを行う」ことだけを考えることが可能です。

次にwhileの方を見てみましょう。
これがwhileを使わざるを得なかった理由は「BufferedReaderには map / flatMap / foreach のような関数が用意されていない」からです。
Scalaでは暗黙の型変換という手法で「存在しない関数を追加する(ように見せかける)」ことも可能ですが、ここではこういう副作用のあるループ的処理で便利に使えるIterator.continuallyという関数を使うことにしましょう。
Iterator.continuallyを使って書き直すと以下のような感じになります。

LoadCSV(抜粋)
  private def loadList(br:BufferedReader) = {
    Iterator.continually(br.readLine()).takeWhile(_ != null).map { str =>
      checkData(str)
    }.collect {
      case Some(data) => data
    }.toList
  }

よく分からないかもしれませんが、ひとまずこんな方法でうまく書けるんだと思っていただければ良いかと思います。
ちなみにcollectというのは「絞り込み(filter)と変換(map)を同時に行う」関数です。

関数合成を活用する

BatchMainにおいてデータが正常に読み込めた際の処理を見てみましょう。大分アレですね。
変数も可変コレクションも使っていないとはいえ、val tempList1とかval tempList2というのはちょっと…
この部分を関数に切り出しつつ、tempList1tempList2を消しましょう。

BatchMain.scala(抜粋)
private def registDatabase(list: List[CSVData]) = 
  RegistDatabase.registActiveUsers(
    DataFilter.filterActiveUser(
      DataCheck.discountHeavyUser(list)))

きれいになりまし…なってませんね。呼び出しが入れ子になっているのでカッコが三重になっています。
一時定数を使わない代わりにその値を取得する関数呼び出しを直接指定しているので当然ですね。
データを中心に考えているとこうなってしまいます。考え方を変えましょう。Scalaは関数型言語ですので、関数を中心に考えてみましょう。
データを受け取って関数1を呼び出してその戻り値の値を引数にして関数2を〜〜〜
と考えるのではなく
関数1と関数2と…の機能を合わせた関数を作り、それを利用する、と考えましょう。
Scala(のような関数型言語)には関数を合成させる機能があります。
数学ではf(x)g(x)の合成を(g ○ f)(x)=g(f(x))のように記述しますよね。
同じように、Scalaでは2つの関数fgg compose f(又はf andThen g)で合成することができます。
先程のものを関数合成で実現すると以下のような感じになります。

BatchMain.scala(抜粋)
private val registDatabase =
  (RegistDatabase.registActiveUsers _) compose
    (DataFilter.filterActiveUser _) compose
      (DataCheck.discountHeavyUser _)

先程のコードと比較すると

  • 関数定義ではなく関数オブジェクトなのでdefからvalになっている
  • objectのメンバである関数を関数オブジェクトに変換するために関数名 _と記述する必要がある
  • 関数定義ではないので引数(list: List[CSVData])の記述が不要
  • 入れ子が解消されている

といった違いがあります。データが明示的に登場しない分意図が明確になったのではないかと思います。

以上で説明してきたようなことを利用して書き直した最終的なコードは以下のような感じになります。

CSVData.scala
case class CSVData(mail: String, time: Long, money: Int)
BatchMain.scala
import java.io._

object BatchMain {
  def main(args: Array[String]) = {
    if (args.length < 1) {
      throw new IllegalArgumentException("CSVファイルを指定してください")
    }
    getFile(args(0)) match {
      case Some(file) => {
        LoadCSV.load(file) match {
          case Right(list) => registDatabase(list)
          case Left(e) => {
            System.err.println("CSVファイルの読み込みに失敗しました")
            throw e
          }
        }
      }
      case None => throw new IllegalArgumentException("CSVファイルを指定してください")
    }
  }
  private def getFile(path: String) = {
    val file = new File(path)
    if (!file.exists || !file.isFile) None else Some(file) 
  }
  private val registDatabase =
    (RegistDatabase.registActiveUsers _) compose
      (DataFilter.filterActiveUser _) compose
        (DataCheck.discountHeavyUser _)
}
LoadCSV.scala
import java.io._
import java.nio.charset.Charset
import java.lang.{Long => JLong}

object LoadCSV {
  def load(file: File):Either[IOException, List[CSVData]] =
    try {
      val br = new BufferedReader(
                 new InputStreamReader(
                   new FileInputStream(file), Charset.forName("UTF-8")))
      autoCloser(br) { cbr =>
        Right(loadList(cbr))
      }
    } catch {
      case e:IOException => Left(e)
    }
  private def autoCloser[A](br: BufferedReader)(f: BufferedReader => A) =
    try {
      f(br)
    } finally {
      br.close()
    }
  private def loadList(br:BufferedReader) =
    Iterator.continually(br.readLine()).takeWhile(_ != null).map { str =>
      checkData(str)
    }.collect {
      case Some(data) => data
    }.toList
  private def checkData(str: String) =
    for {
      (mail, sTime, sMoney) <- split(str)
      time <- parseLong(sTime)
      money <- parseInt(sMoney)
    } yield CSVData(mail, time, money)
  private def split(str: String) = {
    val strs = str.split(",")
    if (strs.length != 3) None else Some((strs(0), strs(1), strs(2)))
  }
  private def parseLong(str: String) =
    try {
      Some(JLong.parseLong(str))
    } catch {
      case _:NumberFormatException => None
    }
  private def parseInt(str: String) =
    try {
      Some(Integer.parseInt(str))
    } catch {
      case _:NumberFormatException => None
    }
}
DataCheck.scala
object DataCheck {
  def discountHeavyUser(list:List[CSVData]):List[CSVData] = for (data <- list) yield discount(data)

  private def discount(data: CSVData) = if (isDiscount(data.time, data.money)) data.copy(money = data.money - 500) else data

  private def isDiscount(time: Long, money: Int) = time >= 18000L || money >= 5000
}
DataFilter.scala
object DataFilter {
  def filterActiveUser(baseList:List[CSVData]):List[CSVData] = 
    for {
      data <- baseList if isActive(data.time, data.money) // ifはwithFilterとして解釈される
    } yield data

  private def isActive(time: Long, money: Int) = time >= 3600 && money >= 1000
}
RegistDatabase.scala
object RegistDatabase {
  def registActiveUsers(list: List[CSVData]) = for (data <- list) println(data) // 仮の処理
}

いかがでしょうか。Javaのコードと比較するとかなりいい感じのコードになっているのではないでしょうか。(まだまだ改善の余地はあるかと思いますが)

最後に

とりあえず現時点でScalaがよく分からない、という人に向けて書いたつもりですがいかがでしたでしょうか。
Scalaでは

  • 状態・副作用を排除する
  • 複雑な手続きを書くのではなく、シンプルな関数を組み合わせることで処理を実現する

ことを意識するとJavaっぽさから抜けだしていけると思います。
ScalaはJavaよりも簡潔に安全で変更しやすいコードが書け(ると思い)ますので、ぜひ活用してみてください。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12