[雑談] ラムダ式の気持ち悪さ

Javaにラムダ式と呼ばれる一種の省略記法があります。

オブジェクトに依存しない式を他のメソッドに渡す際に式を直接書くことができれば簡潔に表現できるだろうということで導入されたものと思いますが、他の記法との間の整合性をとるためと考えられる制約によって何となく気持ち悪い仕様になっている気がします。

整列 (ソート) について

ラムダ式の必要性を示すのによく使われるのが整列 (ソート) です。

与えられた値のリストを値の大小に従って昇順または降順に並べ替える処理が整列 (昔は分類という呼び方が普通にされていて、アルゴリズムの目的がよくわからず結構悩みました) です。

整列を行うには値の大小比較ができなければいけないのですが、比較の方法は値の型ごとに異なっているので、あまり考えずに作ろうとすると値の型ごとに整列メソッドを用意する必要が発生します。

例えば以下のような感じです。

void sortInt(List<Int> int_list) { ... }
void sortString(List<String> string_list); { ... }

さすがにこれではやってられないので、Comparableインタフェースを使用するという手もあります。

Comparableインタフェースはオブジェクト間の大小比較を行うcompareToメソッドの存在を保証するので、大小比較が可能となります。

これを使って整列のメソッドを作ると以下のような感じになります。

<T extends Comparable<T>> void sort(List<T> list) { ... }

こうすると値の大小関係が値の型によらず決定できるので整列ができるようになります。

しかし、残念ながら問題がもう一つ残っています。

例えば社員番号と氏名を記録している従業員というクラスがあったとします。

社員番号順に整列させたいという要求と氏名順に整列させたいという要求の2種類の要求があった場合には整列順序ごとに従業員のクラスを作る必要ができてしまいます。

たとえばこんな感じです。

public class Employee {
  int id;
  String name;

}

public class EmployeeComparableById extends Employeee implements Comparable<EmployeeComplarableById> {

  public EmplyeeComparableyId(Employee employee) {

    this.id = employee.id;

    this.name = employee.name;

  }

  public int compareTo(EmployeeComparableById employee) {

    return this.id - employee.id;

  }

}

public class EmployeeComparableByName extends Employeee implements Comparable<EmployeeComplarableByName> {

  public EmplyeeComparableyId(Employee employee) {

    this.id = employee.id;

    this.name = employee.name;

  }

  public int compareTo(EmployeeComparableById employee) {

    return this.name.compareTo(employee.name);

  }

}

こんな感じで面倒になるうえ、以下に示すように整列を行う前に従業員のリストの各要素から整列方法に応じたクラスのインスタンスを生成して新しいリストを生成する必要もあります。

List<EmployeeSortById> new_list = List<EmployeeSortById>();
for (Employee employee : list) {
  new_list.add(new EmployeeSortById(employee));

}

Collections.sort(new_list);

さすがにこれはやってられないので、javaでは大小比較を行うためのメソッドを定義するオブジェクトを使う形式になりました。

まず、2個の値の間の大小関係を解決するcompare(o1, o2) を定義したComparatorインタフェースを用意し、それを整列を行うsortに渡します。

class CompareById implements Comparator<Employee> {
  public int compare(Employee e1, employee e2) {
    return e1.id - e2.id;

  }

}


class CompareByName implements Comparator<Employee> {

  public int compare(Employee e1, employee e2) {

    return e1.name.compareTo(e2.name);

  }

}


public static void main(String[] args) {

  List<Employee> list = ..;

  ...

  Collections.sort(list, new CompareById());

  ...

  Collections.sort(list, new CompareByName));

}

だいぶすっきりしてきましたが、大小比較のために用意したこれらのクラスが整列で使用される場面とは離れた場所に定義されるため、どういった比較がされるのかを確認するのが面倒という問題が出てきました。

無名クラス

1回限りでしか使われないクラスであれば名前を付ける必要はほぼありませんし、その場で定義できれば何をやっているのかがわかりやすいだろうということで無名クラスというものが用意されました。

上の例を無名クラスで書くとこんな感じになります。

public static void main(String[] args) {
  List<Employee> list;
  ...

  Collecions.sort(list, new Comparator<Employee>() {

    public int compare(Employee e1, Employee e2) {

      return e1.id - e2.id;

    });

...

}

キーワードnewでインタフェースのインスタンスを作るような書き方をして、そのあとにインターフェースの実装を記述します。これで指定したインタフェースを実装するオブジェクトが作られます。

こういった場合に使用される大小比較は簡単なものが多いので、プログラムの流れをそれほど乱さずに書くことができます。

しかし、まあ冗長なところがいくつかあります。

まず、Collections.sortの引数定義で2番目の引数にはComparator<Employee>を実装するクラスが入ることが推測できるので、「new Comparator<Employee>」を書くのは無駄といえば無駄です。

また、Comparator<Employee>にはcompare(Employee e1, Employee e2)」しかメソッドが定義されていないので、「int compare(Employee e1, Employee e2」と書かなくてもわかるだろうと言いたくなります。

ラムダ式

無名クラスを使用する際の冗長性を排除して、さらに簡潔に表現しようというのがラムダ式です。

ラムダ式は「(引数リスト) -> メソッドの実装」というかなり簡潔な書き方になります。

上の例をラムダ式に書き換えると以下のようになります。

public static void main(String[] args) {
  List<Employee> list;
  ...

  Collecions.sort(list, (e1, e2) -> e1.id - e2.id);

  ...

}

ラムダ式を使うとかなりあっさりした表記になることがわかるかと思います。

最初の「(e1, e2)」はcompare()の引数で、「->」の後ろがそれを使った式になります。

式では引数の値を使用する必要があるので、引数リストは必須です。引数を必要としない式もあり得ますが、その場合もカッコだけは書かないとラムダ式としてコンパイラが認識できないので、「() -> ...」みたいな書き方になります。

また、式が1文で済まない場合は「-> { ...; return foo; }」のように中かっこでくくり、値を返す文はreturn文とする必要があります。中かっこでくくられているとコンパイラが式として認識しないので、return文が必要になるのでしょう。

ラムダ式の気持ち悪さ

そういった細かい仕様に面倒な点もありますが、それ以上に気持ち悪いと思うのはラムダ式が式でありながら式として動作しない点です。

ラムダ式の制約事項として、引数で使われる名前がスコープ内で使用されているとコンパイルできないという点です。

例えば、下のような場合です。

public static void main(String[] args) {
  List<Employee> list;
  ...

  Empoyee e1;

  ...

  Collecions.sort(list, (e1, e2) -> e1.id - e2.id);

  ...

}

上の例ではラムダ式の(e1, e2)がe1, e2という変数を定義しているとみなされるため、同じ変数を2回定義したとしてコンパイルエラーが発生します。

しかし、実際の動作ではラムダ式の引数e1, e2はそれが属しているmainメソッドで使用されることはなく、mainメソッドの処理の流れとは全く別の文脈で解釈されるものです。

上の例を以下のように無名クラスで書き直すとコンパイルエラーは当然ながら発生しません。

public static void main(String[] args) {
  List<Employee> list;
  ...

  Employee e1;

  ...

  Collecions.sort(list, new Comparator<Employee>() {

    public int compare(Employee e1, Employee e2) {

      return e1.id - e2.id;

  });

  ...

}

基本的にはこれら二つのコード例は表記法の違いだけで同じ動作を表現しているにもかかわらず、異なる制約が課せられるところがいやらしいところと思います。

もう一つ、より本質的な気持ち悪さとして暗黙のうちに謎のオブジェクトが生成される点があります。

Javaには関数オブジェクトあるいはそれに類するものがないので、メソッドが1個だけ (実際にはObject型から継承したメソッドがいくつかありますが) を実装した中身のないオブジェクトで代用しているのでしょう。

それで実用上は何の問題もないのですが、一貫性のなさというか、とってつけた感が気持ち悪いというのが正直なところです。

まあ、気持ち悪いといっていても仕方ないので、そういうものと思っていきましょうかね。



0コメント

  • 1000 / 1000