コンストラクタ内での引数バリデーションやアクセス制御を諦めた話
概要
Javaではインスタンスを生成する際に、不正なオブジェクトの生成を防ぐためにコンストラクタで引数のバリデーションをしたりアクセス制御を行ったりします。しかしコンストラクタの定義には制約が多く、ロジックを表現出来る幅が大変限られてしまいます。そこでコンストラクタへのロジックの記述を排除して積極的にファクトリメソッドを採用し、コンストラクタを呼び出す事が出来るプログラム要素をコーディング規約で制限することで可読性や計算の合成可能性を優先します。
サンプルコード
改善対象のサンプルコードです。vavrを利用しています。
//コンストラクタにロジックが記述されているクラス //getterやいくつかのコマンドメソッドを持つ public final class Task { private final long taskID; private final String name; private final String description; private final boolean wasCompleted //完全コンストラクタ public Task(long taskID, String name, String description, boolean wasCompleted) { requireNonNull(name); requireNonNull(description); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); this.taskID = taskID; this.name = name; this.description = description; this.wasCompleted = wasCompleted; } //初期生成用コンストラクタ public Task(long taskID, String name, String description) { this(taskID, name, description, false); } public Task edit(String newName, String newDescription) { return new Task(this.taskID, newName, newDescription, this.wasCompleted); } public Task complete() { return new Task(this.taskID, this.name, this.description, true); } public Task unComplete() { return new Task(this.taskID, this.name, this.description, false); } public long taskID() { return this.taskID(); } public String name() { return this.Name(); } public String description() { return this.description(); } public boolean wasCompleted() { return this.wasCompleted(); } @Override public int hashCode() { return Long.hashCode(taskID); } @Override public boolean equals(Object o) { return this == o || o instanceof Task task && this.id ==task.id; } }
//永続化層 インターフェース定義省略 public final class TaskRepositoryImpl implements TaskRepository { //略 public Option<Task> findByID(long taskID) { var dtoOption = ...; return dtoOption .map(dto -> new Task( //コンストラクタ呼び出し dto.taskID, dto.newName, dto.newDescription, dto.wasCompleted )); } }
//Taskを利用するアプリケーションロジック public class TaskApplicationService { //略 @Transactional public TaskID createTask(String name, String description) { var taskIdPublisher = ...; try { //コンストラクタ呼び出し var task = new Task(idPublisher.nextID(), name, description) taskRepository.save(task); return task.taskID(); } catch(Exception e) { //必要な例外翻訳 } } }
コンストラクタにロジックを含む問題点
コンストラクタを用いたロジックの記述には以下の問題があります。
そもそもアクセス制御が十分に機能しない
Javaのアクセス制御はprivatte, package-private, protected, publicの4種類しかなく、特に別のパッケージに所属するクラスに対して細かいアクセス制御を行うことが出来ません。 例えば「このファクトリからはアクセスを許可する」や「この型のサブタイプからはアクセスを許可する」といった制約は表現出来ません。またrecord構文を採用する場合、コンストラクタの可視性はrecord本体の可視性に固定されます。
インスタンス生成に名前を付けられない
コンストラクタ呼び出しはnew <クラス名>(<引数>)で固定されます。
コンストラクタのオーバーロードはシグネチャからその意図を判断するのが難しく、利用側では誤った呼び出しをする可能性があります。
//ユースケース層での新規タスク生成 var idPublisher = ...; /*OK*/ var newTask = new Task(idPublisher.nextID(), "MyTask", "YOU MUST DO THIS NOW."); /*NG*/ var redundancyNewTask = new Task(idPublisher.nextID(), "MyTask", "YOU MUST DO THIS NOW.", false); //永続化層からの復元 /*OK*/ var restoredTask = new Task(0L, "MyTask", "YOU MUST DO THIS NOW.", true); /*NG*/ var restoreFailedTask = new Task(0L, "MyTask", "YOU MUST DO THIS NOW.");
上段のredundancyNewTaskの生成では「タスクが初期状態で未完である」というロジックがユースケース層に漏れている状態です。下段のrestoreFailedTaskでは誤ってコンストラクタ第四引数を省略してしまっているため、本来trueとして永続化されてるべき情報がfalseとなって復元されています。
戻り値の型が固定される
コンストラクタ呼び出しの戻り値の型は、そのクラスの実装型で固定されます。言い換えれば、戻り値としてOptionやEither, Validationといった型を使うことが出来ないため、呼び出し失敗を通知する方法が例外に固定されます。
更に、仮に例外を使うイディオムとEitherを使うイディオムの両方を提供したい場合、Either版でもコンストラクタ呼び出しは回避できず、結果不要なバリデーションが走ることになります。
public final class Task { //略 //完全コンストラクタ public Task(long taskID, String name, String description, boolean wasCompleted) { requireNonNull(name); requireNonNull(description); if(name.isBlank() || name.contains("\n") || ) throw new IllegalArgumentException(); this.taskID = taskID; this.name = name; this.description = description; this.wasCompleted = wasCompleted; } //戻り値が固定であるためEither<String, Task> Task(....) {...}のような事は出来ない。 //呼び出し失敗は例外として通知されるため、失敗原因を追跡するにはtry-catch文が必須となる。 public static ofEither(long taskID, String name, String description, boolean wasCompleted) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> //ここでコンストラクタが呼ばれるが、既にバリデーション済みの要素に対して //更にコンストラクタ内で不要なバリデーションを行っている new Task(taskID, name, description, wasCompleted) ) ); } private static Either<String, String> nameEither(String name) { if(name == null) return Either.left("name must not be null."); if(name.isBlank()) return Either.left("name must not be blank."); if(name.contains("\n")) return Either.left("name must be single line."); return Either.right(name); } private static Either<String, String> descriptionEither(String description) { if(description == null) return Either.left("description must not be null."); return Either.right(description); } //略 }
対策
コンストラクタへのロジック記述を一切取りやめます。コンストラクタは単にフィールドに対してプリミティブ値や必要な参照のコピーをする事に徹します。コンストラクタを目的別にオーバーロードすることはせず、用途に合わせたstaticなファクトリメソッド、又はファクトリクラスを用意します。
またコンストラクタのアクセス制御を諦め、代わりにコンストラクタを呼び出すことが出来るプログラム要素を規約で以下に限定します。
- 実装クラス自身
- ファクトリクラス
ただしいずれの場合もコンストラクタ呼び出し時に整合性を担保する必要があります。
クラスのクライアントはコンストラクタを直接呼び出すのではなく、必ずファクトリメソッドやファクトリクラスを経由してインスタンス生成します。これによりクライアントコードが宣言的な記述になります。
コンストラクタを公開する副次的な効果として、フィールドの公開を許容出来る場合はrecord構文を使う事でクラスの記述が簡潔になります。
コンストラクタがロジックを持たなくなった事でEither版のファクトリを定義する事が出来ます。コンストラクタ呼び出しは引数のフィールドへのコピー以外何も行わないため、呼び出し毎に不要なバリデーションが走ることが無いため、かえって再利用しやすくなります。
実装
まず、Taskのコンストラクタを完全コンストラクタ1つのみにして、その他のコンストラクタは削除、用途に合わせてファクトリメソッドを定義します。これにより、インスタンス生成に名前を付けることが出来ます。
public final class Task { private final long taskID; private final String name; private final String description; private final boolean wasCompleted //完全コンストラクタ //publicかつロジック無し public Task(long taskID, String name, String description, boolean wasCompleted) { this.taskID = taskID; this.name = name; this.description = description; this.wasCompleted = wasCompleted; } //永続層からの復元用ファクトリメソッド //完全コンストラクタと同様全フィールドを受け取る。 public static restore(long taskID, String name, String description, boolean wasCompleted) { requireNonNull(name); requireNonNull(description); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); return new Task(taskID, name, description, wasCompleted); } //初期生成用ファクトリメソッド //いわゆるコマンドメソッド public static create(long taskID, String name, String description) { requireNonNull(name); requireNonNull(description); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); return new Task(taskID, name, description, false); } //略 }
次にバリデーションメソッドをファクトリの外側で定義し、各ファクトリで再利用します。
public final class Task { //略 //永続層からの復元用ファクトリメソッド //完全コンストラクタと同様全フィールドを受け取る。 public static restore(long taskID, String name, String description, boolean wasCompleted) { validateName(name); validateDescription(description); return new Task(taskID, name, description, wasCompleted); } //初期生成用ファクトリメソッド //いわゆるコマンドメソッド public static create(long taskID, String name, String description) { validateName(name); validateDescription(description); return new Task(taskID, name, description, false); } //nameに対するバリデーションメソッド private static validateName(String name) { requireNonNull(name); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); } //descriptionに対するバリデーションメソッド private static validateDescription(String description) { requireNonNull(description); } //略 }
更に状態更新用のヘルパファクトリメソッドを定義し、コマンドメソッドを再実装します。
public final class Task { //フィールド、コンストラクタ、ファクトリ略 public Task edit(String newName, String newDescription) { validateName(newName); validateDescription(newDescription); return update(newName, newDescription, this.wasCompleted); } public Task complete() { return update(this.name, this.description, true); } public Task unComplete() { return update(this.name, this.description, false); } //略 //状態更新用メソッド //ライフサイクルを通じて不変であるtaskIDを引き継ぐ private Task update(String name, String description, boolean wasCompleted) { return new Task(this.taskID, name, description, wasCompleted); } }
今回のTaskはclassの代わりにrecordを使うことで記述を大幅に省略できます。ただし自動生成されるメソッドのうちhashCodeとequalsは再実装します。
record版のTaskの実装の全文を以下に示します。
public record Task(long taskID, String name, String description, boolean wasCompleted) { //フィールド不要 //コンストラクタも不要 //永続層からの復元用ファクトリメソッド //完全コンストラクタと同様全フィールドを受け取る。 public static restore(long taskID, String name, String description, boolean wasCompleted) { validateName(name); validateDescription(description); return new Task(taskID, name, description, wasCompleted); } //初期生成用ファクトリメソッド //いわゆるコマンドメソッド public static create(long taskID, String name, String description) { validateName(name); validateDescription(description); return new Task(taskID, name, description, false); } public Task edit(String newName, String newDescription) { validateName(newName); validateDescription(newDescription); return update(newName, newDescription, this.wasCompleted); } public Task complete() { return update(this.name, this.description, true); } public Task unComplete() { return update(this.name, this.description, false); } @Override public int hashCode() { return Long.hashCode(taskID); } @Override public boolean equals(Object o) { return this == o || o instanceof Task task && this.id ==task.id; } //nameに対するバリデーションメソッド private static validateName(String name) { requireNonNull(name); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); } //descriptionに対するバリデーションメソッド private static validateDescription(String description) { requireNonNull(description); } //状態更新用メソッド //ライフサイクルを通じて不変であるtaskIDを引き継ぐ private Task update(String name, String description, boolean wasCompleted) { return new Task(this.taskID, name, description, wasCompleted); } }
続いてこれらを利用する永続層とアプリケーションサービスを再実装します。これらは元々直接コンストラクタを呼び出していましたが、「コンストラクタを呼び出すことが出来るのは自身かファクトリ」のルールに従いファクトリメソッドを使うようにします。
//永続化層 インターフェース定義省略 public final class TaskRepositoryImpl implements TaskRepository { //略 public Option<Task> findByID(long taskID) { var dtoOption = ...; return dtoOption .map(dto -> Task //ファクトリメソッド呼び出し。 .restore(dto.taskID, dto.newName, dto.newDescription, dto.wasCompleted) ); } }
//Taskを利用するアプリケーションロジック public class TaskApplicationService { //略 @Transactional public TaskID createTask(String name, String description) { var taskIdPublisher = ...; try { //ファクトリメソッド呼び出し var task = Task.create(idPublisher.nextID(), name, description) taskRepository.save(task); return task.taskID(); } catch(Exception e) { //必要な例外翻訳 } } }
コンストラクタ呼び出しと比べてクライアントコードが宣言的になりました。createとrestoreを間違えることは考えにくく、引数の個数間違いによるロジック漏れやバグを防ぐことが出来ます。
続いてEither版のAPIを定義します。これらは元の例外版ファクトリメソッドを一切汚すことなく追加できます。 ただしバリデーションロジックの共有は難しいので実際はどちらか片方を導入する事になるかと思います。
public record Task(long taskID, String name, String description, boolean wasCompleted) { //略 //Either版ファクトリメソッド public static Either<String, Task> restoreOfEither(long taskID, String name, String description, boolean wasCompleted) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> //コンストラクタは不要なバリデーションを行わない new Task(taskID, validName, validDescription, wasCompleted) ) ); } public static Either<String, Task> createOfEither(long taskID, String name, String description) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> new Task(taskID, validName, validDescription, false) ) ); } //Either版コマンドメソッド public Either<Task> edit(String newName, String newDescription) { return nameEither(newName).flatMap(validName -> descriptionEither(newDescription).flatMap(validDescription -> update(validName, validDescription, this.wasCompleted) ) ); } //略 //Either版バリデーションロジック private static Either<String, String> nameEither(String name) { if(name == null) return Either.left("name must not be null."); if(name.isBlank()) return Either.left("name must not be blank."); if(name.contains("\n")) return Either.left("name must be single line."); return Either.right(name); } private static Either<String, String> descriptionEither(String description) { if(description == null) return Either.left("description must not be null."); return Either.right(description); } //略 }
Either版APIを利用する場合の永続化層、アプリケーションロジックの実装は省略します。
懸念
この手法はコンストラクタが完全に公開されてしまうため、コンストラクタの誤った使い方をコンパイルエラーににすることが出来ません。しかしコンストラクタ呼び出しに関するルールは単純であり、ソースコードの静的解析ツールを作れるかもしれません。
Appendix
改善後のTaskのコード全文を以下に示します。
public record Task(long taskID, String name, String description, boolean wasCompleted) { //フィールド不要 //コンストラクタも不要 //永続層からの復元用ファクトリメソッド //完全コンストラクタと同様全フィールドを受け取る。 public static restore(long taskID, String name, String description, boolean wasCompleted) { validateName(name); validateDescription(description); return new Task(taskID, name, description, wasCompleted); } //初期生成用ファクトリメソッド //いわゆるコマンドメソッド public static create(long taskID, String name, String description) { validateName(name); validateDescription(description); return new Task(taskID, name, description, false); } public Task edit(String newName, String newDescription) { validateName(newName); validateDescription(newDescription); return update(newName, newDescription, this.wasCompleted); } //Either版ファクトリメソッド public static Either<String, Task> restoreOfEither(long taskID, String name, String description, boolean wasCompleted) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> //コンストラクタは不要なバリデーションを行わない new Task(taskID, validName, validDescription, wasCompleted) ) ); } public static Either<String, Task> createOfEither(long taskID, String name, String description) { return nameEither(name).flatMap(validName -> descriptionEither(description).flatMap(validDescription -> new Task(taskID, validName, validDescription, false) ) ); } //Either版コマンドメソッド public Either<Task> edit(String newName, String newDescription) { return nameEither(newName).flatMap(validName -> descriptionEither(newDescription).flatMap(validDescription -> update(validName, validDescription, this.wasCompleted) ) ); } public Task complete() { return update(this.name, this.description, true); } public Task unComplete() { return update(this.name, this.description, false); } @Override public int hashCode() { return Long.hashCode(taskID); } @Override public boolean equals(Object o) { return this == o || o instanceof Task task && this.id ==task.id; } //nameに対するバリデーションメソッド private static validateName(String name) { requireNonNull(name); if(name.isBlank() || name.contains("\n")) throw new IllegalArgumentException(); } //descriptionに対するバリデーションメソッド private static validateDescription(String description) { requireNonNull(description); } //Either版バリデーションロジック private static Either<String, String> nameEither(String name) { if(name == null) return Either.left("name must not be null."); if(name.isBlank()) return Either.left("name must not be blank."); if(name.contains("\n")) return Either.left("name must be single line."); return Either.right(name); } private static Either<String, String> descriptionEither(String description) { if(description == null) return Either.left("description must not be null."); return Either.right(description); } //状態更新用メソッド //ライフサイクルを通じて不変であるtaskIDを引き継ぐ private Task update(String name, String description, boolean wasCompleted) { return new Task(this.taskID, name, description, wasCompleted); } }
Javaによる型制約を持ったインスタンスメソッドの模倣
概要
Javaではレシーバーが特定のParameterized Typeである時のみ呼び出す事が出来るインスタンスメソッドを定義するという言語機能はありません。しかし、Parameterized Typeを引数に取る関数を外部で定義し、ジェネリック型には外部から渡された関数を自身のインスタンスで評価するメソッドを定義する事により模倣することができます。
型制約
インスタンスメソッドを呼び出す事が出来る条件として型変数の継承関係を取る事を本記事では型制約と呼びます。
型制約のモチベーションを確認するために次のコードを考えましょう。
public final class Holder<T> { private final T t; public Holder(T t) { this.t = t; } public T t() { return t; } // ネストしたHolderを「潰す」 // Holder<Holder<T>> -> Holder<T> public <R> Holder<R> flatten() { // どうやって実装する? } // Holder<String> -> Holder<String> -> Holder<String> // 保持した文字列とrightの文字列を結合 public Holder<String> concat(Holder<String> right) { // ??? } }
これらのメソッドはいずれも型制約を必要とするメソッドであり、残念ながらJavaにはこれらをインスタンスメソッドとして実装する方法は存在しません。
型制約を行えない事への対策としてstaticなメソッドとして実装する場合がありますが、これはあまり使い勝手がよくありません。
次の例を考えてみます。
public class Holder<T> { //略 public static <T> Holder<T> flatten(Holder<Holder<T>> nested) { return nested.t(); } public static Holder<Integer> increment(Holder<Integer> intHolder) { return new Holder(intHolder.t()+1); } public static Holder<String> concat(Holder<String> left, Holder<String> right) { return new Holder(left.t() + right.t()); } public static void example() { var nested = new Holder(new Holder(new Holder(1))); var s1 = new Holder("s1"); var s2 = new Holder("s2"); var s3 = new Holder("s3"); var one = Holder.flatten(Holder.flatten(nested)).t(); var s1s2s3 = Holder.concat(Holder.concat(s1, s2), s3).t(); // 理想的な呼び出し // var one = nested.flatten().flatten().t(); // var s1s2s3 = s1.concat(s2).concat(s3).t(); } }
インスタンスメソッドとしての呼び出しでは無いのでHolderが引数リストの中に移動しています。
注目すべきはoneやs1s2s3を取り出す式の可読性が著しく損なわれている事です。やりたい事は二重にネストしたHolderから中身を取り出す、3つの文字列を連結した結果を取り出す、というだけなのに引数の括弧がネストしてしまっています。これらは理想的にはコメントのようにメソッドチェーンを用いて呼び出したい所です。このように型制約を必要とするメソッドは特にメソッドチェーンを利用するAPIを設計したい場合には厄介です。
手法
型制約を模倣するための手順は次の通りです。
- 型制約を行いたいクラスに、自身を引数に取る関数を受け取り、自身で評価した結果を返すジェネリックなメソッドを定義する
- インスタンスメソッドとしての引数を受け取り、型引数の制限により型制約を表現した関数オブジェクトを生成するメソッドを定義する。
opメソッドとoperation生成メソッド
本記事の手法を理解する上で鍵になる考え方はインスタンスメソッドを、関数にインスタンスを部分適用できる仕組みと見なすことが出来るという事です。このことを抽象化するために、データ型から見て自身と同じ型を引数に取る関数をoperationと呼び、operationを自身で評価するメソッドをopメソッドと呼びます。
まずはHolderにopメソッドを定義します。
public final class Holder<T> { private final T t; public Holder(T t) { this.t = t; } public T t() { return t; } public <R> op(Function<? super Holder<T>, ? extends R> operation) { return operation.apply(this); }
次にoperationを生成するメソッドを定義します。
public class OPSHolder { public static <T> Function<Holder<Holder<T>>, Holder<T>> flatten() { return tt -> tt.t(); } public static Holder<String> concat(Holder<String> right) { return left -> new Holder(left.t() + right.t()); } }
利用側は次のようになります。
import static OPSHolder.*; //略 public static void example() { var nested = new Holder(new Holder(new Holder(1))); var s1 = new Holder("s1"); var s2 = new Holder("s2"); var s3 = new Holder("s3"); var one = nested.op(flatten()).op(flatten()).t(); var s1s2s3 = s1.op(concat(s2)).op(concat(s3)).t(); // nestedが受け取るoperationの型はFunction<? super Holder<Holder<Integer>>, ? extends R> // concatの型はFunction<Holder<String>, Holder<String>> // concatはoperationのサブタイプでは無いためコンパイルエラー var CE = nested.op(concat(s1)); }
opメソッドの呼び出しや括弧が二重になる事がボイラープレートにはなりますが、opメソッドを利用する事でconcatやflattenをあたかも型制約を持つインスタンスメソッドであるかのように呼び出すことが出来ています。更にCEのように関数の型制約を満たさないデータ型に対してはその関数を渡すことが出来ないため、誤ったデータ型に対するメソッド呼び出しをコンパイルエラーにする事が出来ます。
インスタンスメソッドの追加
この手法の場合Kotlinの拡張関数のように、外部で定義したメソッドをインスタンスメソッドのように呼び出すことができるようになります。次の例ではインスタンスメソッドをHolderではなくOPSHolderに追加しています。
public class OPSHolder { //略 //保持した値をインクリメントする public static Function<Holder<Integer>, Holder<Integer>> increment() { return ti -> new Holder(ti.t()+1); } public static void example() { var ti = new Holder(1); var three = ti.op(increment()).op(increment()).t(); } }
今回はOPSHolderにメソッドを追加しましたが、incrementを定義する場所はOPSHolderである必要はありません。他クラスのstaticメソッドやインスタンスメソッド、あるいはローカル変数に関数として定義する事も出来ます。
宣言側変性の模倣
境界付きワイルドカード型引数を利用してキャストメソッドを用意する事でJavaには不可能な宣言側変性を模倣する事が出来ます。
public class OPSHolder { //略 //共変 @SuppressWarnings("unchecked") public static <T> Function<Holder<? extends T>, Holder<T>> cast() { return t -> (Holder<T>)t } /* //反変 @SuppressWarnings("unchecked") public static <T> Function<Holder<? super T>, Holder<T>> cast() { return t -> (Holder<T>)t } */ public static void example() { var intHolder = new Holder(1); Holder<Number> numHolder = intHolder.op(cast()); } }
この手法は特に反変キャストが定義出来るのが強力です。Javaでは次のメソッドはコンパイルエラーになります。
public class Holder<T> { //略 //型変数の下限型は定義できないためコンパイルエラー public <A super T> Holder<A> cast() { return (Holder<A>)this; } }
宣言側変性を模倣する場合castメソッドをstaticメソッドとして用意しますが、この方法はflattenやconcatの場合と同様にoperation化する事で、opメソッドを介してインスタンスメソッドのように呼び出すことができます。
宣言側変性に限らず、型の一致ではなく継承関係を利用する場合には同様にワイルドカート型引数を使うことができます。
opメソッドの抽象化
opメソッドの実装は次のように再帰的ジェネリクス型を用いる事で抽象化出来ます。
public interface Operand<X extends Operand<X>> { @SuppressWarning("unchecked") default X cast() { return (X)this; } default <R> op(Function<? super X, ? extends R> operation) { return operation.apply(cast()); } } // Overrideを禁止するための骨格実装 public abstract class OperandClass<X extends OperandClass<X>> implements Operand<X> { protected AbstractOperand() {} public final X cast() { return Operand.super.cast(); } public final <R> op(Function<? super X, ? extends R> operation) { return Operand.super.op(operation); } }
新たなデータ型ではXにその型を与えてOperandを実装する事でopメソッドを利用できるようになります。スーパータイプを持たないのであればOperandClassを継承する事でメソッドのオーバーライドを封じる事が出来ます。
結論
Javaでは型制約を持つインスタンスメソッドを定義することは出来ませんが、型引数に対する制約を利用する事により型制約を模倣する事は出来ます。データ型には自身を引数に取る関数を自身で評価するopメソッドを定義し、好きな場所に型引数に制約持つ関数であるoperationを生成するメソッドを定義します。データ型のopメソッドが受け取る関数の型と、生成された関数の型が異なる場合コンパイルエラーにする事が出来ます。メソッドを追加したい場合はoperationを好きな場所に定義すれば良く、データ型は一切編集する必要がありません。
operationの引数の型引数を型とする事で型の一致関係を条件にできる他、ワイルドカード型引数を利用する事で継承関係を条件にする事も出来ます。特に下限付きワイルドカード型引数を利用する事でインスタンスメソッドでは定義出来ない反変な型変数を表現できます。
opメソッドの実装は上記のOperand型により抽象化する事が出来ます。
Iterableを直接実装しない
概要
コレクションにIterableを実装させる事で、そのコレクションのインスタンスを拡張for文の対象にでき、簡単にイテレート出来るようになります。しかし、IterableではIteratorを返すメソッドを1つしか定義出来ないため、拡張for文によるコレクションの走査順を切り替えることが出来ません。コレクションがIterableを実装する代わりに、そのコレクションに異なる走査順に対応するIterableを返すメソッドを複数個定義するIterableProviderパターンを採用する事でイテレート時の走査順の切り替えが柔軟な設計になります。
イテレート、及びIterableとIteratorの関係
イテレートとは、コレクションからある順番で要素を取り出し、それを用いて次々と副作用を生成する手続きの事を表します。これを抽象化したclass設計の1つがIteratorパターンとしてまとめられており、JavaのIterableとIteratorはIteratorパターン元に定義されているインターフェースです。コレクションを表すclassでIterableを実装し、適切な走査順で値を返すIteratorを用いてiteratorメソッドを実装するのが通常の設計手法です。
JavaにはIteratorパターンによるイテレートを簡単に行うための拡張for文という構文があり、それは次のコードと等価な処理の糖衣構文となっています。
Iterable<T> iterable = ...; for(Iterator<T> iterator = iterable.iterator(); iterator.hasNext();) { T next = iterator.next(); //nextを用いて副作用を生み出す処理 }
拡張for文ではまずIterableのiteratorメソッドよりIterableの要素を順番に返すIteratorを取得します。続いてIteratorのhasNextメソッドがtrueを返し続ける限りIteratorのnextメソッドを呼び出し続け、副作用を生成し続けます。hasNextがfalseを返すとイテレートは終了し、拡張for文を抜ける仕組みになっています。
Iteratorパターンの問題
Iteratorパターンには拡張for文によるイテレート時の走査順を自由に切り替えられないという問題があります。
一般にコレクションに対しての走査順は複数存在します。Iteratorパターンでは走査順の定義がIteratorの実装に現れており、拡張for文による走査順はIteratorの実装により決まります。しかし拡張for文ではIterableのiteratorメソッドで定義されているIterator以外は利用できません。例えばIterableが複数のメソッドにより異なるIteratorを提供していたとしても、拡張for文ではそれらを使い分ける事が出来ません。従ってコレクションがIterableを実装した場合、拡張for文によるイテレートの走査順は1つに限られてしまいます。
この問題を回避するためには、拡張for文によるイテレート時に使われるIteratorを切り替えられる仕組みが必要になります。
IterableProviderパターン
拡張for文ではイテレートに利用するIteratorを切り替えることは出来ませんが、イテレート対象であるIterableは切り替えることができます。IterableとIteratorはIterableの定義より一対一で対応しているため、拡張for文に渡すIterableを切り替えることはIteratorを切り替える事と等しいです。
これを基に、拡張for文により使われるIteratorを切り替えられるようにするために、IterableProviderパターンという設計手法を提案します。IterableProviderパターンの基本的なアイデアはコレクションとIterableを同一視しない事です。Iteratorの実装にはコレクションの実装が必要ですが、一方でIterableの実装に必要なのはコレクションではありません。あくまで必要なのはIteratorの実装だけです。従ってIterableとコレクションは分離する事が出来ます。Iteratorパターンによる設計手法ではコレクションがIterableを直接実装しますが、[IterableProviderパターン]ではコレクションが走査順に対応するIterableを返すメソッドを複数定義する設計にします。コレクションが提供するそれぞれのIterableは走査順に対応するIteratorを返すように実装します。
このように設計する事で、コレクションのユーザは拡張for文に渡すIterableをコレクションから提供されるIterableの中から選択することにより、求める走査順でイテレートを行うことができます。
二分木に対するIterableProviderパターンの実装
二分木を対象にIterableProviderパターンを実装します。二分木には様々な走査順があり得ますが、ここでは前順、間順、後順の深さ優先探索、及び幅優先探索を実装し、それらを実際に拡張for文で利用する事で走査順が切り替えられることを示します。
実装時に以下のライブラリを利用しています。
なおここで用いるコードはGitHubリポジトリで公開されています。
データ構造
二分木のノードを表すクラスは次のように実装されています。
package raystark.iterablesample; import org.jetbrains.annotations.NotNull; import raystark.eflib.option.Option; import raystark.eflib.option.Option.None; public final class Node<T> { private final T value; private final Option<Node<T>> left; private final Option<Node<T>> right; public Node(@NotNull T value, @NotNull Option<Node<T>> left, @NotNull Option<Node<T>> right) { this.value = value; this.left = left; this.right = right; } public Node(@NotNull T value) { this(value, None.of(), None.of()); } @NotNull public T value() { return value; } @NotNull public Option<Node<T>> getLeft() { return left; } @NotNull public Option<Node<T>> getRight() { return right; } }
Nodeはleftとrightの2つの子、及び値valueを持ちます。それぞれの子が存在しない可能性があるため、子のNodeはOption型のフィールドで保持しています。子のNode、及び値はコンストラクタでのみ初期化されます。コンストラクタはvalueと left、rightを全て受け取るものの他、子の存在しないノードを生成するための簡易版も定義されています。
コレクション
IterableProviderパターンの対象となるコレクションである二分木は次のように定義されています。
package raystark.iterablesample; import org.jetbrains.annotations.NotNull; import raystark.eflib.option.Option; public final class BinaryTree<T> { private final Option<Node<T>> root; private final Iterable<T> preorder; private final Iterable<T> inorder; private final Iterable<T> postorder; private final Iterable<T> breadthFirst; public BinaryTree(@NotNull Option<Node<T>> root) { this.root = root; this.preorder = () -> new PreorderIterator<>(this); this.inorder = () -> new InorderIterator<>(this); this.postorder = () -> new PostorderIterator<>(this); this.breadthFirst = () -> new BreadthFirstIterator<>(this); } public @NotNull Option<Node<T>> root() { return this.root; } public @NotNull Iterable<T> preorderIterable() { return preorder; } public @NotNull Iterable<T> inorderIterable() { return inorder; } public @NotNull Iterable<T> postorderIterable() { return postorder; } public @NotNull Iterable<T> breadthFirstIterable() { return breadthFirst; } }
BinaryTree自体はIterableを実装せず、代わりにコンポジションにより各走査順に対応するIterableをフィールドに保持しています。各Iterableの実装はIteratorの実装にのみ依存しており、コレクション自体には一切依存していません。BinaryTreeには根のノードを取得するrootメソッドが定義されており、後述のIteratorではこれを用いて走査アルゴリズムを実装しています。
Iterableには@FunctionalInterfaceアノテーションが付与されていませんが、iteratorメソッドのみを抽象メソッドとしてもつ関数型インターフェースであるため、ここではラムダ式で実装されています。
Iterator
前順、間順、後順、幅優先探索のそれぞれに対応するIteratorは次のように実装されています。
前順
import raystark.eflib.option.Option; import java.util.ArrayDeque; import java.util.Deque; import java.util.Iterator; //参考 http://n00tc0d3r.blogspot.com/2013/08/implement-iterator-for-binarytree-ii.html final class PreorderIterator<T> implements Iterator<T> { private final Deque<Node<T>> stack; PreorderIterator(@NotNull BinaryTree<T> tree) { this.stack = new ArrayDeque<>(); tree.root().ifPresent(stack::push); } @Override public boolean hasNext() { return !stack.isEmpty(); } @Override public @NotNull T next() { return Option.ofNullable(stack.poll()) .whenPresent(polledNode -> { polledNode.getRight().ifPresent(stack::push); polledNode.getLeft().ifPresent(stack::push); }) .orElseThrow() .value(); } }
間順
package raystark.iterablesample; import org.jetbrains.annotations.NotNull; import raystark.eflib.option.Option; import java.util.ArrayDeque; import java.util.Deque; import java.util.Iterator; // 参考 http://n00tc0d3r.blogspot.com/2013/08/implement-iterator-for-binarytree-i-in.html final class InorderIterator<T> implements Iterator<T> { private final Deque<Node<T>> stack; InorderIterator(@NotNull BinaryTree<T> tree) { this.stack = new ArrayDeque<>(); tree.root().repeatMapWithSideEffect(Node::getLeft, stack::push).ifPresent(stack::push); } @Override public boolean hasNext() { return !stack.isEmpty(); } @Override public T next() { return Option.ofNullable(stack.poll()) .whenPresent( leaf -> leaf.getRight() .repeatMapWithSideEffect(Node::getLeft, stack::push) .ifPresent(stack::push) ).orElseThrow() .value(); } }
後順
package raystark.iterablesample; import org.jetbrains.annotations.NotNull; import raystark.eflib.option.Option; import java.util.ArrayDeque; import java.util.Deque; import java.util.Iterator; //参考 http://n00tc0d3r.blogspot.com/2013/08/implement-iterator-for-binarytree-iii.html final class PostorderIterator<T> implements Iterator<T> { private final Deque<Node<T>> stack; PostorderIterator(@NotNull BinaryTree<T> tree) { this.stack = new ArrayDeque<>(); tree.root().repeatMapWithSideEffect(this::down, stack::push) .ifPresent(stack::push); } private Option<Node<T>> down(Node<T> node) { return node.getLeft().or(node.getRight()); } @Override public boolean hasNext() { return !stack.isEmpty(); } @Override public T next() { return Option.ofNullable(stack.poll()) .whenPresent( leaf -> Option.ofNullable(stack.peek()) .filter(parent -> parent.getLeft().anyMatch(leaf)) .flatMap(Node::getRight) .repeatMapWithSideEffect(this::down, stack::push) .ifPresent(stack::push) ) .orElseThrow() .value(); } }
幅優先探索
package raystark.iterablesample; import org.jetbrains.annotations.NotNull; import raystark.eflib.option.Option; import java.util.ArrayDeque; import java.util.Iterator; import java.util.Queue; final class BreadthFirstIterator<T> implements Iterator<T> { private final Queue<Node<T>> queue; BreadthFirstIterator(@NotNull BinaryTree<T> tree) { queue = new ArrayDeque<>(); tree.root().ifPresent(queue::offer); } @Override public boolean hasNext() { return !queue.isEmpty(); } @Override public T next() { return Option.ofNullable(queue.poll()) .whenPresent(polledNode -> { polledNode.getLeft().ifPresent(queue::offer); polledNode.getRight().ifPresent(queue::offer); }) .orElseThrow() .value(); } }
各Iteratorはコンストラクタで後述のBinaryTreeを受け取っています。各Iteratorの実装は外部に公開する必要が無いため、package-privateなクラスとして宣言されています。
なお、前順、間順、後順のアルゴリズムの実装はソースコード上のURLの記事を参考にしました。
Main
これらを実際に利用するMainクラスは次のように実装されています。
package raystark.iterablesample; import raystark.eflib.option.Option.Some; public class Main { public static void main(String[] args) { var tree = new BinaryTree<>( Some.of(new Node<>(6, Some.of(new Node<>(2, Some.of(new Node<>(1)), Some.of(new Node<>(4, Some.of(new Node<>(3)), Some.of(new Node<>(5)) )) )), Some.of(new Node<>(10, Some.of(new Node<>(8, Some.of(new Node<>(7)), Some.of(new Node<>(9)) )), Some.of(new Node<>(11)) )) ) )); System.out.println("Preorder"); for(var value : tree.preorderIterable()) System.out.print(" " + value); System.out.println(); System.out.println("Inorder"); for(var value : tree.inorderIterable()) System.out.print(" " + value); System.out.println(); System.out.println("Postorder"); for(var value : tree.postorderIterable()) System.out.print(" " + value); System.out.println(); System.out.println("BreathFirst"); for(var value : tree.breadthFirstIterable()) System.out.print(" " + value); System.out.println(); } }
最初に二分木を構築し、その二分木が提供するIterableをそれぞれ拡張for文に渡して標準出力に出力しています。
二分木の構造は次のようになっています。
6 / \ 2 10 / \ / \ 1 4 8 11 / | | \ 3 5 7 9
実行例
Mainの実行例を示します。
Preorder 6 2 1 4 3 5 10 8 7 9 11 Inorder 1 2 3 4 5 6 7 8 9 10 11 Postorder 1 3 5 4 2 7 9 8 11 10 6 BreathFirst 6 2 10 1 4 8 11 3 5 7 9
IterableProviderパターンを採用する事で、拡張for文で同じコレクションに対する複数の走査順によるイテレートが出来る事が確認できます。
考察
IterableProviderに対する走査順の追加
Iteratorパターンではコレクションに対する走査順が1つに限られるため、新たな走査順を追加することが出来ません。しかしIterableProviderパターンでは提供されるIterableが1つとは限らない上にそれぞれのIterable及びIteratorはIterableProviderに対して独立して実装されます。従ってコレクションに対しては新たな走査順に対応するIterableを返すメソッドを後から自由に行うことができます。この拡張性の高さはIteratorパターンには無い利点です。
IterableProviderパターンの他の実装方法
二分木に対するIterableProviderパターンの実装ではIteratorを外部で定義し、IterbleProviderが提供するIterableをフィールドに保持していました。つまりIterableProviderはデータ構造と複数のIterableからなるコンポジションでした。コンポジションによる実装の他に内部クラスを利用する方針が考えられます。つまりIterableProviderがIterableを非staticな内部クラスとして定義する方針です。この方針の場合IterableProviderを実装するclassの定義にIterable及びIteratorの定義が現れるためIterableProviderのコードが長大になる可能性があります。簡易な実装で済むのであれば良いですが、拡張すればするほどソースコードが膨れ上がる事を留意する必要があります。その代わりIteratorの実装をパッケージに対して隠蔽出来る利点があります。
IterableProviderに対応する型
IterableProviderパターンではIterableProviderに対応する型の定義が難しいです。IteratorパターンではIterableが提供するIteratorが1つに定まっていましたが、IterableProviderパターンではIterableProviderが提供するIterableが一つに定まっていません。更にそれらのIterableはイテレート順が区別できるように名前が付けられなければならないため、単純にIterableのコレクションを返すわけにもいきません。IterableProviderが返すIterableの実装はコレクションに依存しているため、IterableProviderパターンはコレクションで実装する他ありませんが、そのコレクションがIterableProviderパターンで実装されている事を型で証明する事は出来ません。
IterableProviderパターンとIteratorパターンの組み合わせ
コレクションをIteratorパターンで実装した場合、コレクション自身がIterableとなり、インスタンスを直接拡張for文に渡すことが出来ます。仮にIteratorパターンで実装されたコレクションがIterableProviderパターンを実装した場合2つのパターンはどのように関係するのでしょうか。
この場合ユーザーがコレクションに対するイテレートを行う際にコレクション自身を拡張for文に渡す方法とコレクションの返すIterableを拡張for文に渡す方法の何れかを選ぶことができます。この時IteableProviderパターンでは[Iteable]に走査順を表す名前を付けられるのに対してコレクション自身には走査順を表す名前を付けることが出来ません。
私はコレクション自身を拡張for文に渡すことは、自然な走査順によりイテレートを行う事だと考えます。例えば配列やリストであれば添字の昇順、順序付きデータ構造であれば順序の昇順等特に違和感無く利用できる走査順です。
IteratorパターンとIterableProviderパターンの組み合わせによる自然な走査順の定義は便利ではありますが、その走査順が本当に自然か否かについては議論の余地があります。例えば二分木に対する間順走査は自明な走査でしょうか、あるいは前順、後順が自然な走査順なのでしょうか。私は機能の一方がデフォルト、他方がオプションという設計はユーザーにどの機能がデフォルトなのかを常に考えさせる負荷を与えるため好ましくないと考えます。従ってIterableProviderパターンとIteratorパターンの組み合わせは避けるべきと考えます。
しかしながら適切なデフォルト動作というのは多くの場合記述量が少なくなるという点では便利です。IterableProviderパターンとIteratorパターンを組み合わせる場合はどの走査順がデフォルトなのか、他にどのような走査順が定義されているのかをコレクションのドキュメントで明記すべきです。
結論
IteralbeProviderパターンを採用する事で、コレクションが複数の走査順を提供できるようになり、ユーザはそれらから適切な走査順を選ぶことが出来るようになります。更にIterableProviderパターンは拡張性に優れており、コレクションに対して後から走査順を追加することも可能になります。しかしながらそのコレクションがIterableProviderパターンで実装されている事を型で示す事は難しいため、実装者はその事についてドキュメントで触れるべきです。
GradleでGitHub Packagesを利用しようとしてハマった話
何があったの?
Gradleを利用して自作したJavaライブラリをGitHub Packagesを使って公開しようとした所いくつか罠にはまりました。今回はハマってしまった罠とその対処法を紹介します。今回の問題にあたってGitHubさんのサポートを利用しましたがGitHubさんのサポートは最高でした。
環境
経緯
ExtendedFunctionLibraryという自作ライブラリを公開しようと思ってリポジトリをどこにしようかなーって調べてたらGitHub Packagesの存在を知りました。GitHub Packagesは作成したソフトウェアパッケージを公開出来るサービスなのですが、なんとGradleプロジェクトを公開して他のプロジェクトから依存出来るらしいのです。普通Bintray等を使うのですがリポジトリを用意する手間って中々大変なんですよね。GitHub PackagesならGitHubとの連携もスムーズ、これは便利だ!てことで勉強を始めたら実際にライブラリを公開するまでにいくつもの罠にハマりました。
罠1: GitHub Packages Disabled
対策: アカウントの支払情報を設定する
これ、めちゃくちゃ困りました。ドキュメントのGitHub Packagesの支払いについてを確認すると
Github PackagesはGitHub Free (中略)で利用できます。
GitHub Packagesの利用は、パブリックパッケージについては無料です。
等明示されています。確認してみたところ私のアカウントはGitHub Free, 作ろうとしているのは無料のパブリックパッケージだったので利用できるはずなのに、実際はGithub Packages自体が無効化されていました。 下記の画像はGitHub プロフィールのPackagesタブですが、ここにGitHub Packages Disabled等と表示されている場合はアカウントの支払情報が登録されていない可能性があります。未設定の場合はSetting -> Billingと進み支払情報を登録しましょう。
罠2: certificate for <maven.pkg.github.com> doesn't match any of the subject alternative names: [*.registry.github.com, registry.github.com]
対策: Gradleで利用するJDKのバージョンを上げる
これは実際にGitHub Packagesにパッケージをアップロードする際に発生したエラーです。これは自力では解決できなかったのでGitHub Supportにメールを送りました。maven.pkg.github.comと言うのはGitHub PackagesにMavenパッケージをアップロードする際にアクセスするドメインになります。エラー文は「これのSSL証明書が代替名と一致しない」と言っています。私は詳しくないのですが、SSL証明書と言うのはクライアント-サーバ間でHTTPS通信を行う(つまりSSL接続を行う)際に必要になるものなのですが、これが古くて新しい物と一致しないため通信が出来ないとの事でした。
この問題についてはGitHub サポートさんより「Gradleで利用するJDKのバージョンを上げてはどうか」と言っていただきました。当時自分がGradleを利用するために使っていたJDKのバージョンは11でしたがこれを14まで上げました。結果このエラーは発生しなくなりました。以下手順を書いておきます
手順1. Gradleで利用するJDKのバージョンアップ
IDEAにて
File
->Settings
->Build, Execution
->Deployment
->Build Tools
->Gradle
と進み、Gradle JVMを14に設定。JVMが無い場合はダウンロードしてきてください。
手順2. Gradleのバージョンアップ
gradle-wrapper.propertiesのdistributionUrlを以下のように書き換える
distributionUrl=https\://services.gradle.org/distributions/gradle-<6.3以上のリリース>-bin.zip
GradleのリリースノートよりGradleでJDK14がサポートされているのは6.3以降との事です。6.3以降を指定してください。私は執筆時点で最新の6.6.1を指定しています。
手順3. build.gradleでプロジェクトのソースファイル、クラスファイルのバージョンを指定
これは必須では無いのですが、Gradleが利用するJVMのバージョンとプロジェクトのバージョンが違う場合に必要になります。ご自身の開発環境に合わせて設定して問題ありません。私は現在Java11で開発してますので次のように設定しました。
ext { javaVersion = JavaVersion.VERSION_11 } java { setSourceCompatibility javaVersion setTargetCompatibility javaVersion }
罠3: Received status code 422 from server: Unprocessable Entity
対策: build.gradleだけでなくsetting.gradleのrootProject.nameを確認する
これはアップロードしようとしている物が何らかの形で不正であるときに発生するHTTPレスポンスです。問題は何がどう不正なのか全く分からない事です。Gradle経由でGitHub Packagesを利用する場合はmaven-publishプラグインを利用してbuild.gradleで設定することになるのですが、今回実はbuild.gradleと直接は無関係な場所が原因でした。それが対策に書かれているsetting.gradleです。
setting.gradleは主にマルチプロジェクト等を設定するときに利用するのですが、ルートプロジェクトのみの場合であってもプロジェクト名を指定するために利用します。実際にプロジェクト名を指定しているのがrootProject.nameプロパティです。maven-publishプラグインを利用する場合配布するJarのデフォルトのartifactIdがrootProject.nameになります。ところでMaven公式ドキュメントによればartifactIdとは配布されるJarファイルからversionを除いたものであり、小文字で変な記号を使ってはいけません(原文ほぼ直訳)。私のライブラリの名前はExtendedFunctionLibraryなのでプロジェクト名をこれにしていたのですが、そのせいでartifactIdに大文字が入り、無効な名前になってしまっていたみたいでした。きちんとMavenのドキュメントを読んでいればわかる事でしたね。こんなの気づくわけねぇだろ。一つ言い訳をするなら私はMavenを使った事が無く、この事を全く知りませんでした…。GradleのドキュメントではMavenを利用することについては書かれているのですがMavenの内容については既知であるのが前提のような説明になっています。きちんと勉強しないとダメですね。以下にMaven周りの設定のチェックリストを残しておきます。
- repositoriesのURLは適切か
- https://maven.pkg.github.com/小文字ユーザー名/リポジトリ名(大文字可)
- リポジトリの認証情報は適切か
credentials { username = project.findProperty "github.username" password = project.findProperty "github.password" }
- artifactIdは適切か
アップロード出来たーーー!!!って消せないんだけど?
>パッケージに依存しているかもしれないプロジェクトが壊れることを避けるために、パブリックなパッケージ全体、あるいはパブリックなパッケージの特定バージョンを削除する事はできません。
— RayStark a.k.a. ロジニキ (@RayStark77) October 13, 2020
^q^https://t.co/NRlU0Ir0zp
ドキュメントはちゃんと読もう。
最後に
如何でしたでしょうか。わかってしまえば簡単な事なんですけどこれらが全部同時に来たので何が問題なのか紐解くのがとても大変でしたw
最後に協力してくださった方々にこの場を借りてお礼申し上げます。手厚く対応してくださったGitHub Supportの皆様、ツイッターで嘆く私に協力してくれた皆様、本当にありがとうございました。
この記事が誰かの役に立てばいいなぁ。
日記2
今日はものすごく久しぶりに趣味のDDRを遊びに行きました。その時間往復の運転3時間+プレイ時間7時間の合計10時間。この日記を書いてる今は体力がすっからかんでヘロヘロです。普段ゲームやったりプログラム書いたりしてても全然体を動かさなくてだんだん体が弱っていっちゃうので楽しみながら思いっきり体を動かすことが出来るDDRにはかなり感謝してます。
普段は現状の最高難易度一個手前の足18に挑むんだけれども今日は久々だからか全然体が追いつきませんでした…。コロナ自粛の時もそうだったんですが、時間をあけると次に動かした時にまず心肺が追いつかなくて息苦しくなって、それがマシになった頃に太腿、脹脛が攣り始めて遊べなくなります。今日も実際足が攣っておしまいでした。
やっぱり少なくとも週一くらいで体を動かさないと中々鍛えられないですね。サボってちゃいけないなと思いました。ところで明日は往復4時間の運転、それも朝の10時からっていう苦行が待っています。大丈夫でしょうかね…w
日記1
折角ブログ書くことになったので気が向くうちは日記を付けて行こうかなと思います。
今丁度大学退学の片付けをしていて、実家に引っ越すために借りていたアパートの荷物を片付けたのですが、今日はようやく荷物が全部なくなりました。沢山あった物が全部きれいさっぱり無くなって、部屋の掃除も全て終わって引っ越してきたばかり見たいな綺麗な部屋になりました。
長く苦しんだ大学生活でしたが綺麗な部屋を見ると気持ちもスッキリしました。あとは2日後19日にガスと生協の方との立ち合いが終われば全部終わり、もうあちらに行く機会は殆ど無くなるんだろうなと思うと少し寂しい気持ちになりました。大学退学はかなり大きな決断でしたが前に踏み出すための第一歩、これから風向きが良くなると良いなと思いました。
Luaでオブジェクト指向1
最近OpenComputersと言うMinecraftのModを使うためにLuaというプログラミング言語を勉強しています。Luaは高速なスクリプト言語で、ゲームで使われることが多い?らしいです。動画投稿者の方はAviUtilなんかで使った事があるかもしれません。今回はそのLua言語でオブジェクト指向をしようと言うお話です。なお、一連の記事はLua5.3をベースにお話します。
そもそも、Luaはクラスベースのオブジェクト指向プログラミング(オブジェクト型)をサポートしていません。つまり自分で新たな型を定義し、オブジェクトの構造を静的に決定することが出来ません。Luaでオブジェクト指向プログラミングをする場合はテーブルという機能を使うことになります。今回はLuaによるオブジェクト指向プログラミングの前提となるテーブルについて解説します。
テーブルの基本
テーブルはLua言語の機能の1つで、データを構造化するために使います。データはキーと値により対応付けされます。以下はテーブルのキーに数値型を使うことでテーブルを配列として使う場合の一例です。
local array = {"one", "two"; "three"} print(array[1]) -- one print(array[2]) -- two print(array[3]) -- three
並括弧{}を「テーブルコンストラクタ」と呼びます。値をカンマ(,)、又はセミコロン(;)で区切ります。配列のインデックスは1始まりになります。これを次のように書き換えることができます。
local array1 = { [1] = "one", [2] = "two", [3] = "three" } local array2 = {} array2[1] = "one" array2[2] = "two" array2[3] = "three"
array1ではテーブルコンストラクタ内でインデックスを指定して値を代入しています。一方、array2では一旦テーブルコンストラクタによりarray2を初期化したのち、後から値を代入しています。Javaの配列型と違い、事前に配列の長さを宣言する必要はありません。
テーブル型は参照型ですので、次のように別の変数に代入した場合はテーブルの参照のみがコピーされます。
local a = {"one"} local b = a a[1] = 1 print(b[1]) -- 1
変数bにはテーブルaの参照がコピーされているため、aの中身を書き換えることでbからアクセスした時も中身が変更されているように見えます。
テーブルのキーには数値型以外にも任意の型を使うことができます。以下は文字列型を使う場合の例です。
local table = { ["one"] = 1, ["two"] = 2, ["three"] = 3 }
キーに文字列型を使う場合は次のように書き換えることができます。
local table1 = { one = 1, two = 2, three = 3 } local table2 = {} table2.one = 1 table2.two = 2 table3.three = 3
table1では文字列リテラルを表すダブルクォート("")とテーブルのキーを表す角括弧を省略しています。table2ではテーブルアクセスに角括弧を使う代わりにピリオド(.)を使っています。
ここまでで、テーブルがC言語の構造体と配列を合わせたような機能だということがわかって頂けたかと思います。Luaのテーブルにはさまざまな省略記法がありますが、これらを組み合わせることであたかもJava等のオブジェクトを操作するかのような記述をすることが出来るようになります。次回は関数とテーブルの利用について解説します。