kagamihogeの日記

kagamihogeの日記です。

JEP 305: Pattern Matchingをテキトーに訳した

2016/02/23 コメントを基に修正。

http://openjdk.java.net/jeps/305 をテキトーに訳した。

JEP 305: Pattern Matching

Author   Brian Goetz
Owner   Gavin Bierman
Created 2017/05/30 19:48
Updated 2017/06/16 16:25
Type    Feature
Status  Candidate
Component   specification/language
Scope   SE
Discussion  amber dash dev at openjdk dot java dot net
Priority    3
Reviewed by Mark Reinhold
Issue   8181287

Summary

Java言語をパターンマッチング(pattern matching)による機能強化を行います。初回のサポートは、switchステートメントmatches式による、型テスト(type-test)と定数パターン(constant patterns)になります。パターンの適用範囲と言語でパターンマッチングをサポートする要素の拡張は、以降の開発で行います。

Motivation

おおむね大半の言語は、ある式が特定の型や構造を持つことのテストと組みわせる一連のロジックがあり、その次に、以降の処理で使うために状態のコンポーネントを条件付きで展開します。たとえば、Javaではinstanceof-and-castイディオムが良く知られています。

if (obj instanceof Integer) {
    int intValue = ((Integer) obj).intValue();
    // use intValue
}

ここには三種類の処理があります。test(xはIntegerである)、conversion(objをintegerにキャスト)、destructuring(IntgerからintValueコンポーネントを抽出)。このパターンは単純でありJavaプログラマには馴染み深いものですが、最適とは言い難い理由がいくつかあります。まず冗長さで、型テストとキャストはやることが被っています(instanceofしたあと他に何をやるというのだろうか?)。キャストとdestructuringという突然現れるボイラープレートにより、それに続く重要なロジックが見辛くなります。しかし最も甚大なのは、ポイラープレートの繰り返しがプログラムにエラーを気付かれないまま紛れ込ませる可能性がある点です。

複数のターゲット型がありうる場合をテストする場合にこの問題は更に悪化します。上述の例をif...elseテストチェーンに書き加えます。

String formatted = "unknown";
if (obj instanceof Integer) {
    int i = (Integer) obj;
    formatted = String.format("int %d", i);
}
else if (obj instanceof Byte) {
    byte b = (Byte) obj;
    formatted = String.format("byte %d", b);
}
else if (obj instanceof Long) {
    long l = (Long) obj;
    formatted = String.format("long %d", l);
}
else if (obj instanceof Double) {
    double d = (Double) obj;
    formatted = String.format(“double %f", d);
}
else if (obj instanceof String) {
    String s = (String) obj;
    formatted = String.format("String %s", s);
}

上のコードは見慣れたものですが、多数のよろしくない要素が含まれています。既に述べたように、こうした繰り返しコードはプログラマを苛立たせます。また、ビジネスロジックはポイラープレートの中にたやすく埋もれてしまいます。しかしより重要なのは、このやり方はコーディングエラーを残したままにしてしまう点で、その理由は過剰な制御構造になっているためです。上のコードの意図は、if...elseチェーンの各箇所でformatted変数に何らかの値を代入することです。しかし、ここで実際に発生することをコンパイラで検証可能にする術がありません。もし、あるブロックが、実際上滅多に実行されないブロックだとして、formattedへの代入を忘れているとバグになります(blank localもしくはblank finalでformattedにしておけば少なくとも"確実な代入"分析には入れられますが、常に行われるわけではありません)。最後に、上のコードは最適化の余地が限られています。コンパイラの英雄が不在で、基底のプログラムはおおむねO(1)なのに、O(n)の複雑さを抱えています。

Description

アドホックな解決策に頼るより、Javaにもパターンマッチング(pattern matching)を入れる時が来たと我々は判断しています。パターンマッチングは1960sにさかのぼる事プログラミング言語でさまざまな異なるスタイルに適用されてきた技術で、SNOBOL4とAWKなどのテキスト指向言語、HaskellとMLなどの関数型言語、最近ではScalaC#などのオブジェクト指向言語にも拡張されています。

パターン(pattern)とはターゲットに適用可能な述語(predicate)の組み合わせです。述語はバインド変数(binding variables)と共に使用し、もし述語が適用される場合はターゲットから抽出されたものがバインド変数になります。バインディングパターンの形の一つは型テスト(type test)パターンで、以下のようなものです(matches演算子は概念的なものです)。

if (x matches Integer i) {
    // can use i here
}

Integer iというフレーズが型テストパターンです。iは新規の変数宣言で、宣言済みの変数ではありません。ターゲットはIntegerインスタンスかどうかテストされ、次に、Integerにキャストされてバインド変数iintコンポーネントがバインドされます。

先に触れたように、if...elseの連続は過度に制御構造を多用しているので望ましくありません。Javaには既にswitchというタコ足(multi-armed)な等価テストの機構があります。しかしswitchは(今のところは)極めて限定的なものです。ごく少数の型、numbers, strings, enums、のみswitch可能で、さらに、定数に対する等価性しかテスト出来ません。とはいえこれら制限はおおむね歴史的経緯なだけであり、switchステートメントはパターンマッチングにパーフェクトに"マッチ"します*1。もしcaseラベルでパターンを指定可能になれば、switchで上述の例を以下のように書けるようになります。

String formatted;
switch (obj) {
    case Integer i: formatted = String.format("int %d", i); break;
    case Byte b:    formatted = String.format("byte %d", b); break;
    case Long l:    formatted = String.format("long %d", l); break;
    case Double d:  formatted = String.format(“double %f", d); break;
    case String s:  formatted = String.format("String %s", s); break
    default:        formatted = obj.toString();
}

これにより、正しく制御構造を使用するようになったため、コードの意図がかなり明瞭になります。つまり、"式objは以下の条件のうち少なくとも一つにマッチし、マッチした行を実行する"という意図を示しています。加えて、最適化可能性も良くなり、この例の場合はO(1)でディスパッチ可能となる可能性が高いです。

従来のcaseラベル(コンパイル時の定数、数値・Stringenum、との比較)は、両者がObject.equals()で等価の場合にターゲットが定数パターンにマッチするという点で、定数パターン(constant patterns)と言えます。なお、マッチした定数パターンは何もバインディングしません。

初回の機能拡張では、定数パターン、switchステートメントでのバインディングmatches式による型テストパターン、のサポートを目的とします。ガード(マッチするにはtrueにならなければならない補助的なboolean式、たとえばcase String s && !s.isEmpty()と、switch内のcontinueステートメント、の両方あるいは片方)をサポートする可能性もあります。

Future Work

型テストパターンとswitchのパターンは最初の一歩に過ぎませんが、それでも明らかに第一歩です。将来的な取り組みの対象となりうる領域(JEPのターゲットになる)には以下があります。

Deconstruction Patterns. クラスには単にデータを保持するものが多くあります。コンストラクタで生成し、その際にN個の引数を取り集約を生成しますが、基本的にはアクセサで一度に一つのコンポーネントをフェッチします。 型テスト-キャスト-バインドの操作と一つの型テストパターンを組み合わせられるので、型テスト-キャスト-複数抽出と一つのdeconstruction patternを組み合わせられます。いま、Nodeの型階層があり、そのサブタイプに、IntNode(単一のintを持つ)、AddNodeMulNode(二つのnodeを持つ)、NegNode(単一のnodeを持つ)があるとすると、Nodeに対するマッチをして特定のサブタイプにおける挙動をすべてワンステップに収められます。

int eval(Node n) {
    switch(n) {
        case IntNode(int i): return i;
        case NegNode(Node n): return -eval(n);
        case AddNode(Node left, Node right): return eval(left) + eval(right);
        case MulNode(Node left, Node right): return eval(left) * eval(right);
        default: throw new IllegalStateException(n);
    };
}

現状、上記のようなアドホックポリモーフィックな計算を表現するには、"Visitor"パターンを使います。パターンマッチングを使うことで、より透過的で単純な理解しやすいものになります。

Nested Patterns. 上の例で既にnested patternを使用しており、deconstruction patternsの"引数"である、Node nなどは、既にこれ自身がパターン(この場合は型テストパターン)です。いま、左側がゼロのIntNodeAddNodeとマッチさせたい場合、もう一段階ネストを追加します。

case AddNode(IntNode(0), Node right)

上の例では、deconstruction pattern(AddNode(...))で左側のコンポーネントが更に別のeconstruction pattern(IntNode(...))にマッチし、このパターンの内側では一つのコンポーネントが定数パターン0にマッチします。

Expression switch. switch statementは今のところステートメントですが、いくつかの選択肢から選ぶ場合、結果を生成してそのまま続ける場合が非常に多いです。switchを式にも出来るようにすることでswitchステートメントで式のようなことを実現せざるを得ない歪みを矯正します。

Sealed types. switchのcaseが網羅的であると事前に分かっていることは有用です。つまり、基本的な状況下では決して実行されないdefaultを書く必要が無い、ということです。階層構造に網羅性を組み込めるのであればクライアントに対し有益な制約を示すことになり、コンパイラの網羅性分析の助けになります。

Alternatives

型テストパターン(deconstruction patternsでは無い)はifswitchステートメントもしくはtype switchflow typingによっても実現できます。パターンはこれら制御構造を汎用化するものです。

Dependencies

実装にはおそらくDynamic Constants in the JVMを使用します。(https://bugs.openjdk.java.net/browse/JDK-8177279).

*1: perfect "match" for pattern matching.が原文で、意図的にmatchを二回出してると思われ、いわゆるダジャレの匂いがする。

Spring BatchのCommandLineJobRunnerで任意の終了ステータスを返す

背景

たとえば、Spring BatchをCommandLineJobRunnerを使用するjavaコマンドで起動し、そのjavaプロセスの終了ステータスをシェルスクリプトで取得して何らかの条件分岐を行いたい、とする。基本的には、Spring Batchはその終了状態に応じて0,1,2を返すのでこれで十分なのだが、それ以外の任意の値を返したい場合がある。

ソースコードなど

環境

  • spring-batch 3.0.7.RELEASE

JVM終了時に呼ばれるSystemExiter

CommandLineJobRunnerはJVM呼び出し時の拡張ポイントとしてpresetSystemExiterを用意しているので、ここで自前のSystemExiterを設定できる。

import org.springframework.batch.core.launch.support.CommandLineJobRunner;
import org.springframework.batch.core.launch.support.SystemExiter;

...
    public static void main(String[] args) throws Exception {
        CommandLineJobRunner.presetSystemExiter(new SystemExiter() {
            public void exit(int status) {
                System.exit(status);
            }
        });
        CommandLineJobRunner.main(args);
    }

ちなみにEclipseで終了ステータスを確認するにはdebugビューで見れる。

f:id:kagamihoge:20170625183723j:plain

afterJobでExitStausのexitCodeを指定してExitCodeMapperで変換

上のコードは固定値だが、exitメソッドのstatusは基本的にはExitStatusの値が入ってくる。ただし、CommandLineJobRunnerデフォルト動作のSimpleJvmExitCodeMapperが0,1,2のいずれかにExitStatusの値を変換する。そのため、それ以外の値にしたければ自前のExitCodeMapperを設定してやる必要がある。

まず、適当な終了ステータスのExitStatusを返すJobExecutionListenerを作る。リスナ設定のXMLなどについては省略。

import org.springframework.batch.core.ExitStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobExecutionListener;

public class JobListener implements JobExecutionListener {

    @Override
    public void beforeJob(JobExecution jobExecution) {
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        jobExecution.setExitStatus(new ExitStatus("114"));
    }

}

自前のExitCodeMapperを作る。ここでは、単にExitStatusのexitCode文字列をintに変換するだけ。上記のJobListener#afterJobでexitCodeに114の文字列を指定しているので、以下のメソッドはintの114を返す。

import org.springframework.batch.core.launch.support.SimpleJvmExitCodeMapper;

public class CustomExitCodeMapper extends SimpleJvmExitCodeMapper {
    @Override
    public int intValue(String exitCode) {        
        return Integer.parseInt(exitCode);
    }
}

次に、CommandLineJobRunnerにsetExitCodeMapperがあるので、これを使用してセッターインジェクションで、上で作成した自前のCustomExitCodeMapperを設定する。方法は色々あるが、たとえばXMLなら以下のようにする。

<bean id="exitCodeMapper" class="kagamihoge.springbatchexitcode.CustomExitCodeMapper" />

ここの仕組みについては、CommandLineJobRunnerは起動時に引数に与えられたxmlをスキャンし、それを自身にも適用している。なので、たとえばsetExitCodeMapperに対応するexitCodeMapperというidのbeanがあればそれをインジェクションする。

最後に、最初に作成したSystemExiterのところを引数の値をそのままSystem.exitの引数に渡すように変更する。

       CommandLineJobRunner.presetSystemExiter(new SystemExiter() {
            public void exit(int status) {
                System.exit(status);
            }
        });

これで、afterJobで何らかの条件分岐を行い、それを基に任意の終了ステータスを返すことが出来る。

参考URL

WildFly 10でLogback使おうとして挫折した話

まとめ

WildFly 10でLogback使うやり方がわからなかったのと、ログの要件がそんなにきつくなかったのでPer-deployment Loggingを使うことにした。

やったこと

pom.xmlLogbackの依存性を追加する。

<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
</dependency>

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.25</version>
</dependency>

main/resources/logback.xmlを適当に作成する。

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>logback - %d{HH:mm:ss.SSS} %-5level %logger{10} %msg%n</pattern>
    </encoder>
  </appender>

  <root level="TRACE">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

適当なテスト用のcontrollerをspring-mvcで作っておく。

@RestController
public class HogeController {
    private Logger logger = LoggerFactory.getLogger(HogeController.class);
    
    @RequestMapping("/i")
    public String index() {
        logger.debug("debug");
        logger.info("info");
        logger.error("error");
        
        return "hoge";
    }
}

この段階ではまだlogbackでのログ出力はできない。

jboss - Override logging in WildFly - Stack Overflowlogging - WIldfly 10 + logback - Stack Overflow にあるように、WebContent/META-INF/jboss-deployment-structure.xml を作成する。wildfly側のloggingのsubsytemをdisableにする必要がある、とのこと。

<jboss-deployment-structure>
  <deployment>
     <!-- exclude-subsystem prevents a subsystems deployment unit processors running on a deployment -->
     <!-- which gives basically the same effect as removing the subsystem, but it only affects single deployment -->
     <exclude-subsystems>
        <subsystem name="logging" />
    </exclude-subsystems>
  </deployment>
</jboss-deployment-structure>

しかしEclipse上のコンソールログがちょっとおかしい。

22:27:27,264 INFO  [stdout] (default task-2) logback - 22:27:27.262 DEBUG k.w.c.HogeController debug

22:27:27,265 INFO  [stdout] (default task-2) logback - 22:27:27.265 INFO  k.w.c.HogeController info

22:27:27,265 INFO  [stdout] (default task-2) logback - 22:27:27.265 ERROR k.w.c.HogeController error

こんな感じに余計な改行が一つ入ってしまっている。

java - Wildfly and logback with blank lines - Stack Overflow と、これを読む限り、wildflySystem.outをラップするために改行がいっこ余計に入る、ということらしい。

じゃあってんで、logback.xmlから改行を取り除くとフラッシュが起きなくなるからかログが出なくなる。<immediateFlush>false</immediateFlush>も意味なし(デフォルトtrueだし)

というわけでstandalone.xmlのフォーマットから改行を取り除いてみる。

            <formatter name="COLOR-PATTERN">
                <pattern-formatter pattern="%K{level}%d{HH:mm:ss,SSS} %-5p [%c] (%t) %s%e"/>
            </formatter>

これでログに余計な改行が無くなるが、今度はwildflyのログの一部(COLOR-PATTERNを使うものと思うが)が改行されなくなる。

logbackが出力するコンソールやファイルへのログは正常に出るんでこれで妥協できなくもないが…

そこでちょっと待てよとlogback.xmlのpatternの末尾を%nではなく\nに変えてみる。

  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>logback - %d{HH:mm:ss.SSS} %-5level %logger{10} %msg\n</pattern>
    </encoder>
  </appender>
22:51:45,733 INFO  [stdout] (default task-2) logback - 22:51:45.730 DEBUG k.w.c.HogeController debug
22:51:45,734 INFO  [stdout] (default task-2) logback - 22:51:45.733 INFO  k.w.c.HogeController info
22:51:45,734 INFO  [stdout] (default task-2) logback - 22:51:45.734 ERROR k.w.c.HogeController error

余計な改行が無くなった。ただしwindowsでしか動かしてないので、プラットフォームや環境が変わると動くかどうかはわからない。つまり場当たり的対処でしかない……

そういうわけで。WildFlyのPer-deployment Logging使っておけば良いのでは? という結論になった。この機能はlog4j.xmlとか特定の名前のログ設定ファイルをwarなどに含めておくと、その設定ファイルを使用してwildflyがログ出力してくれるもの。ロギングのライブラリの依存性をwarに含める必要が無く、基本的にはslf4j-apiをprovidedで入れるだけ。

ログの要件的にはこれで十分だったんで、まぁいっかとこれで妥協することにした。