Pavel Fatinは 、 JetBrainsの IntelliJ IDEAのScalaプラグインに取り組んでいます 。
はじめに
この記事では、古典的なScala 設計パターンがどのように実装されるかの例を示します。
この記事の内容は、 JavaDayカンファレンスでの私のプレゼンテーションの基礎となります( プレゼンテーションのスライド )。
設計パターン
デザインパターンは、ソフトウェア開発の一般的な問題に対する一般的な再利用可能なソリューションです。 設計パターンは、特定の問題を解決するために使用する完全なコードではなく、特定のニーズに適合させる必要があるテンプレートにすぎません。 設計パターンは、コードの可読性を向上させ、開発プロセスをスピードアップするのに役立つ効果的な設計のベストプラクティスです。
機能設計パターン
古典的なデザインパターンはオブジェクト指向です。 クラスとオブジェクト間の関係と相互作用を示します。 これらのモデルは、純粋な関数型プログラミングではあまり適用できません(「関数型」設計パターンについては、 HaskellのTypeclassopediaおよびScalazを参照してください)。
設計パターンは、プログラミング言語にない必要な構成要素の代わりになる場合があります。 言語に必要な機能がある場合、パターンを簡素化または完全に削除できます。 Scalaの世界では、ほとんどの古典的なパターンは、表現力豊かな構文構造を使用して実装されています。
Scalaには独自の追加パターンがありますが、この記事では開発者間の相互理解の鍵であるため、古典的なデザインパターンのみを説明します。
ここに挙げた例は、 Scala言語のフルパワーを使用してより表現的に実装できるという事実にもかかわらず、 Java実装がScala実装にどのように関連するかをより明確に理解するために、単純で明快なものを優先して複雑なトリックを回避しようとしました。
設計パターンの概要
ジェネレーティブデザインパターン( 創造的パターン ):
構造パターン :
行動パターン:
次に、設計パターンの実装が提供されます(すべてのコードはGithubリポジトリで利用可能です )。
設計パターンの実装
工場工法
ファクトリメソッドパターンは 、オブジェクトを作成するためのインターフェイスを提供し、作成するオブジェクトのクラスに関する決定をカプセル化するために使用されます。
このパターンを使用すると、次のことができます。
- 複雑なオブジェクトを作成するロジックを組み合わせて、
- クラスを選択してオブジェクトを作成し、
- キャッシュオブジェクト
- 共有リソースへのアクセスを調整します。
Factory Methodパターンの古典的なバージョンとはわずかに異なるStatic Factory Methodパターンの実装について、以下で説明します。 。
Javaでは、コンストラクタを呼び出すことにより、 new演算子を使用してクラスをインスタンス化します。 テンプレートを実装するとき、コンストラクタを直接使用するのではなく、特別なメソッドを使用してオブジェクトを作成します。
public interface Animal {}
public class Dog implements Animal {}
public class Cat implements Animal {}
public class AnimalFactory { public static Animal createAnimal(String kind) { if ("cat".equals(kind)) { return new Cat(); } if ("dog".equals(kind)) { return new Dog(); } throw new IllegalArgumentException(); } }
パターンの使用例は、犬の作成です。
Animal animal = AnimalFactory.createAnimal("dog");
コンストラクターに加えて、 Scalaはコンストラクターの呼び出しに似た特別な構文構成体を提供しますが、実際には便利なファクトリーメソッドです。
trait Animal private class Dog extends Animal private class Cat extends Animal object Animal { def apply(kind: String) = kind match { case "dog" => new Dog() case "cat" => new Cat() } }
使用例:
Animal("dog")
ファクトリメソッドは、いわゆる「コンパニオンオブジェクト」で定義されます-同じソースファイルで定義された同じ名前の特別なシングルトンオブジェクト。この構文は、オブジェクトの作成をサブクラスに委任することができなくなるため、パターンの「静的」実装に制限されます。
長所:
- 基本クラス名を再利用します。
- 標準的かつ簡潔。
- デザイナーの呼び出しを思い出させます。
短所:
- 「静的」ファクトリメソッド。
遅延初期化
遅延初期化は、 遅延コンピューティングの特殊なケースです。 このパターンは、最初のアクセスでのみ値(またはオブジェクト)を初期化するためのメカニズムを提供するため、コストのかかる計算を延期(または回避)できます。
通常、 Javaでパターンを実装する場合、初期化されていない状態を示すために特別なnull値が使用されます。 ただし、 nullが有効な初期化値である場合、初期化状態を示すために追加のフラグが必要です。 マルチスレッドコードでは、競合状態を回避するために、このフラグへのアクセスを同期する必要があります。 同期を改善するには、ダブルチェックロックを使用できます。これにより、コードがさらに複雑になります。
private volatile Component component; public Component getComponent() { Component result = component; if (result == null) { synchronized(this) { result = component; if (result == null) { component = result = new Component(); } } } return result; }
使用例:
Component component = getComponent();
Scalaは、より簡潔な組み込みメカニズムを提供します。
lazy val x = { print("(computing x) ") 42 }
使用例:
print("x = ") println(x) // x = (computing x) 42
Scalaの遅延初期化は、 null値でもうまく機能します 。 保留中の値へのアクセスはスレッドセーフです。
長所
- 短い構文。
- 遅延値はヌル値にも使用できます。
- 保留中の値はスレッドセーフです。
短所
- 初期化の制御が少ない。
シングルトン(シングルトン)
シングルトンパターンは、クラスのインスタンスの数、つまり1つのオブジェクトのみを制限するメカニズムを提供し、このオブジェクトへのグローバルアクセスポイントも提供します。 おそらくJavaで最も広く知られているデザインパターンであるシングルトンは、言語に必要な構成がないことの明確な兆候です。
Javaには、オブジェクト(クラスインスタンス)との通信がないことを示す特別なキーワードstaticがあります。 このキーワードでマークされたメソッドは、継承中にオーバーライドできません。 このような概念は、すべてがオブジェクトであるという基本的なOOP原則に反します。
そのため、このパターンは、何らかのインターフェイスのグローバル実装へのアクセスが必要な場合に使用されます(おそらく初期化が遅延します)。
public class Cat implements Runnable { private static final Cat instance = new Cat(); private Cat() {} public void run() { // do nothing } public static Cat getInstance() { return instance; } }
使用例:
Cat.getInstance().run()
ただし、パターンのより経験豊富な実装(初期化の遅延を伴う)は、大量のコードで記述され、さまざまな種類のエラー(たとえば、 「二重チェック付きブロック」 )につながる可能性があります。
Scalaは、このパターンを実装するためのコンパクトなメカニズムを提供します。
object Cat extends Runnable { def run() { // do nothing } }
使用例:
Cat.run()
Scalaでは、オブジェクトはクラスまたはインターフェースからメソッドを継承できます。 オブジェクトを参照できます(直接またはレガシーインターフェイス経由)。 Scalaでは、オブジェクトは要求に応じて遅延して初期化されます。
長所
- 透過的な実装。
- コンパクトな構文。
- 初期化の遅延。
- スレッドセーフ。
短所
- 初期化の制御が少ない。
アダプター
アダプタパターンは、あるインターフェイスを別のインターフェイスに変換するメカニズムを提供し、これらの異なるインターフェイスを実装するクラスが連携して動作できるようにします。
アダプタは、既存のコンポーネントを統合するのに便利です。
Javaでの実装では、コードで明示的に使用されるラッパークラスを作成します。
public interface Log { void warning(String message); void error(String message); } public final class Logger { void log(Level level, String message) { /* ... */ } } public class LoggerToLogAdapter implements Log { private final Logger logger; public LoggerToLogAdapter(Logger logger) { this.logger = logger; } public void warning(String message) { logger.log(WARNING, message); } public void error(String message) { logger.log(ERROR, message); } }
使用例:
Log log = new LoggerToLogAdapter(new Logger());
Scalaには、インターフェースを適合させるための特別な組み込みメカニズム(暗黙的なクラス)があります。
trait Log { def warning(message: String) def error(message: String) } final class Logger { def log(level: Level, message: String) { /* ... */ } } implicit class LoggerToLogAdapter(logger: Logger) extends Log { def warning(message: String) { logger.log(WARNING, message) } def error(message: String) { logger.log(ERROR, message) } }
使用例:
val log: Log = new Logger()
Logタイプの使用が想定されていますが、 ScalaコンパイラーはLoggerクラスをインスタンス化し、アダプターでラップします。
長所
- 透過的な実装。
- コンパクトな構文。
短所
- IDEを使用せずに混乱する可能性があります。
デコレータ
デコレータパターンは、同じクラスの他のインスタンスに影響を与えずにオブジェクトの機能を拡張するために使用されます。 デコレータは、継承の柔軟な代替手段を提供します。 このパターンは、機能を拡張する独立した方法がいくつかあり、それらを任意に組み合わせることができる場合に使用すると便利です。
Javaで実装すると、ベースインターフェイスを継承し、元のクラスをラップする新しいデコレータクラスが定義され、複数のデコレータをネストできます。 中間デコレータークラスは、いくつかのメソッドを委任するためによく使用されます。
public interface OutputStream { void write(byte b); void write(byte[] b); } public class FileOutputStream implements OutputStream { /* ... */ } public abstract class OutputStreamDecorator implements OutputStream { protected final OutputStream delegate; protected OutputStreamDecorator(OutputStream delegate) { this.delegate = delegate; } public void write(byte b) { delegate.write(b); } public void write(byte[] b) { delegate.write(b); } } public class BufferedOutputStream extends OutputStreamDecorator { public BufferedOutputStream(OutputStream delegate) { super(delegate); } public void write(byte b) { // ... delegate.write(buffer) } }
使用例:
new BufferedOutputStream(new FileOutputStream("foo.txt"));
同じ目標を達成するために、 Scalaは特定の実装に縛られることなく、インターフェースメソッドをオーバーライドする直接的な方法を提供します。
trait OutputStream { def write(b: Byte) def write(b: Array[Byte]) } class FileOutputStream(path: String) extends OutputStream { /* ... */ } trait Buffering extends OutputStream { abstract override def write(b: Byte) { // ... super.write(buffer) } }
使用例:
new FileOutputStream("foo.txt") with Buffering // with Filtering, ...
委任はコンパイル時に静的に設定されますが、オブジェクトの作成時にデコレータを任意に組み合わせることができる限り、通常これで十分です。
構成ベースの実装とは異なり、ScalaのアプローチはオブジェクトのIDを保持するため、装飾されたオブジェクトでequalsを安全に使用できます。
Scalaでは、この装飾アプローチはStackable Trait Patternと呼ばれます。
長所
- 透過的な実装。
- コンパクトな構文。
- オブジェクトの識別が保存されます。
- 明示的な委任の欠如。
- 中間デコレータークラスの欠如。
短所
- 静的バインディング。
- パラメーターなしのコンストラクター。
値オブジェクト
値オブジェクトは小さな不変の値です。 すべてのフィールドが等しい場合、値オブジェクトは等しくなります。 値オブジェクトは、数値、日付、色などを表すために広く使用されています。 企業アプリケーションでは、そのようなオブジェクトはプロセス間の相互作用のためのDTOオブジェクトとして使用されます。不変であるため、値オブジェクトはマルチスレッドプログラミングで便利です。
Javaには値オブジェクトを作成するための特別な構文はありませんが、代わりに、コンストラクター、ゲッターメソッド、および追加のメソッド(equals、hashCode、toString)でクラスが作成されます。
public class Point { private final int x, y; public Point(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } public boolean equals(Object o) { // ... return x == that.x && y == that.y; } public int hashCode() { return 31 * x + y; } public String toString() { return String.format("Point(%d, %d)", x, y); } }
使用例:
Point point = new Point(1, 2)
Scalaでは、タプルまたはケースクラスを使用して値オブジェクトを宣言できます。 別のケースクラスが不要な場合は、タプルを使用できます。
val point = (1, 2) // new Tuple2(1, 2)
タプルは、さまざまなタイプの固定数の要素を含むことができる事前定義された不変の「コレクション」です。 タプルは、コンストラクター、ゲッターメソッド、およびすべてのヘルパーメソッドを提供します。
type Point = (Int, Int) // Tuple2[Int, Int] val point: Point = (1, 2)
それでも識別クラスが必要な場合、またはデータ要素のよりわかりやすい名前が必要な場合は、ケースクラスを定義できます。
case class Point(x: Int, y: Int) val point = Point(1, 2)
ケースクラスは、クラスコンストラクターパラメーターのプロパティを作成します。 デフォルトでは、ケースクラスは不変です。 タプルと同様に、必要なすべてのメソッドを自動的に提供します。 さらに、ケースクラスは有効なクラスです。つまり、ケースクラスは通常のクラスのように操作できます(たとえば、継承します)。
長所
- コンパクトな構文。
- 定義済みの言語構成体はタプルです。
- 組み込みの必要なメソッド。
短所
欠席しています。
ヌルオブジェクト
nullオブジェクトは、 オブジェクトが存在しないことで、中立の「非アクティブ」な動作を定義します。
このアプローチは、使用前にリンクを明示的に確認する必要がないため、 nullリンクの使用よりも優先されます。
Javaでは、パターンの実装は、「空の」メソッドを持つ特別なサブクラスを作成することです。
public interface Sound { void play(); } public class Music implements Sound { public void play() { /* ... */ } } public class NullSound implements Sound { public void play() {} } public class SoundSource { public static Sound getSound() { return available ? music : new NullSound(); } } SoundSource.getSound().play();
これで、次のplayメソッドの呼び出しの前にgetSoundメソッドが呼び出されたときに受信したリンクを確認する必要がなくなりました。 さらに、Nullオブジェクトをシングルトンにすることができます。
Scalaは、オプションの値の「コンテナ」として使用できる事前定義されたOptionタイプを使用して、同様のアプローチを使用します。
trait Sound { def play() } class Music extends Sound { def play() { /* ... */ } } object SoundSource { def getSound: Option[Sound] = if (available) Some(music) else None } for (sound <- SoundSource.getSound) { sound.play() }
長所
- 定義済みのタイプ。
- 明示的なオプション。
- オプションの値を操作するための組み込み言語構造。
短所
- 冗長性。
戦略
戦略パターンは、カプセル化されたアルゴリズムのファミリを定義し、それを使用するクライアントに影響を与えることなく、独自にアルゴリズムを変更できます。 実行時にアルゴリズムを変更する必要がある場合、パターンを使用すると便利です。
Javaでは、パターンは通常、ベースインターフェイスを継承するクラスの階層を作成することで実装されます。
public interface Strategy { int compute(int a, int b); } public class Add implements Strategy { public int compute(int a, int b) { return a + b; } } public class Multiply implements Strategy { public int compute(int a, int b) { return a * b; } } public class Context { private final Strategy strategy; public Context(Strategy strategy) { this.strategy = strategy; } public void use(int a, int b) { strategy.compute(a, b); } } new Context(new Multiply()).use(2, 3);
Scalaには一流の関数があるため、パターンの概念は言語自体を使用して実装されます。
type Strategy = (Int, Int) => Int class Context(computer: Strategy) { def use(a: Int, b: Int) { computer(a, b) } } val add: Strategy = _ + _ val multiply: Strategy = _ * _ new Context(multiply).use(2, 3)
戦略アルゴリズムに複数のメソッドが含まれる場合、ケースクラスまたはタプルを使用してメソッドをグループ化できます。
長所
- コンパクトな構文。
短所
- 汎用。
コマンド
パターンコマンドは 、リモートポイントでメソッドを呼び出すために必要な情報をカプセル化するために使用されます。 この情報には、メソッドの名前、メソッドが属するオブジェクト、およびメソッドパラメーターの値が含まれます。
Javaでは、パターンの実装は呼び出しをオブジェクトにラップすることです。
public class PrintCommand implements Runnable { private final String s; PrintCommand(String s) { this.s = s; } public void run() { System.out.println(s); } } public class Invoker { private final List<Runnable> history = new ArrayList<>(); void invoke(Runnable command) { command.run(); history.add(command); } } Invoker invoker = new Invoker(); invoker.invoke(new PrintCommand("foo")); invoker.invoke(new PrintCommand("bar"));
Scalaには、遅延コンピューティング用の特別なメカニズムがあります。
object Invoker { private var history: Seq[() => Unit] = Seq.empty def invoke(command: => Unit) { // by-name parameter command history :+= command _ } } Invoker.invoke(println("foo")) Invoker.invoke { println("bar 1") println("bar 2") }
これにより、任意の式またはコードブロックをオブジェクト関数に変換できます。 printlnメソッドの呼び出しは、メソッドの呼び出し内で行われ、その後、 履歴シーケンスに保存されます。
長所
- コンパクトな構文。
短所
- 汎用。
責任の連鎖
責任の連鎖パターンにより、リクエストの送信者と受信者が分離され、複数のエンティティがリクエストを処理できるようになります。 リクエストは、いくつかのオブジェクトが処理するまでチェーンによって処理されます。
典型的なパターン実装では、チェーン内の各オブジェクトはベースインターフェイスを継承し、チェーン内の次の処理オブジェクトへのオプションの参照を含みます。 各オブジェクトには、リクエストを処理する(および割り込み処理する)か、チェーン内の次のハンドラにリクエストを渡す機会が与えられます。
public abstract class EventHandler { private EventHandler next; void setNext(EventHandler handler) { next = handler; } public void handle(Event event) { if (canHandle(event)) doHandle(event); else if (next != null) next.handle(event); } abstract protected boolean canHandle(Event event); abstract protected void doHandle(Event event); } public class KeyboardHandler extends EventHandler { // MouseHandler... protected boolean canHandle(Event event) { return "keyboard".equals(event.getSource()); } protected void doHandle(Event event) { /* ... */ } }
使用例:
KeyboardHandler handler = new KeyboardHandler(); handler.setNext(new MouseHandler());
Scalaは、このような問題を解決するためのより洗練されたメカニズム、つまり部分関数を提供します 。 部分関数は、引数の可能な値のサブセットで定義された関数です。
isDefinedAtとapplyメソッドの組み合わせを使用してチェーンを構築できますが、 getOrElseメソッドを使用する方が適切です。
case class Event(source: String) type EventHandler = PartialFunction[Event, Unit] val defaultHandler: EventHandler = PartialFunction(_ => ()) val keyboardHandler: EventHandler = { case Event("keyboard") => /* ... */ } def mouseHandler(delay: Int): EventHandler = { case Event("mouse") => /* ... */ }
keyboardHandler.orElse(mouseHandler(100)).orElse(defaultHandler)
「未定義」イベントでのエラーを回避するために、 ここでdefaultHandlerが使用されることに注意することが重要です。
長所
- コンパクトな構文。
- 組み込みの言語構造。
短所
- 汎用。
依存性注入
依存関係注入パターンは、ハードコーディングされた依存関係を回避し、実行時またはコンパイル時に依存関係を置き換えます。 パターンは、 制御の反転(IoC)の特殊なケースです。
依存性注入は、アプリケーション内の特定のコンポーネントのさまざまな実装から選択するため、または単体テスト用の 模擬コンポーネントを提供するために使用されます。
IoCコンテナーの使用に加えて、このパターンをJavaで実装する最も簡単な方法は、コンストラクターパラメーターを使用して、インターフェイスの特定の実装(クラスがその作業で使用する)を渡すことです。
public interface Repository { void save(User user); } public class DatabaseRepository implements Repository { /* ... */ } public class UserService { private final Repository repository; UserService(Repository repository) { this.repository = repository; } void create(User user) { // ... repository.save(user); } } new UserService(new DatabaseRepository());
構成(「HAS-A」)と継承(「IS-A」)に加えて 、 Scalaは特別な種類のオブジェクト関係を提供します- 自己タイプの注釈で表現される要件(「REQUIRES-A」) 。 自己型を使用すると、継承を適用せずにオブジェクトに追加の型を指定できます。
特性とともに自己型注釈を使用して、依存性注入パターンを実装できます。
trait Repository { def save(user: User) } trait DatabaseRepository extends Repository { /* ... */ } trait UserService { self: Repository => // requires Repository def create(user: User) { // ... save(user) } } new UserService with DatabaseRepository
この方法論の完全な実装は、 Cakeパターンとして知られています 。 ただし、これがパターンを実装する唯一の方法ではなく、 Scalaに依存性注入パターンを実装する他の多くの方法もあります。
Scalaでの特性の 「混合」 は静的であり、コンパイル時に発生することを思い出してください。 ただし、実際には、構成の変更はそれほど頻繁には必要ありません。また、コンパイル段階での静的検証の追加の利点には、XML構成よりも大きな利点があります。
長所
- 透過的な実装。
- コンパクトな構文。
- コンパイル時の静的チェック。
短所
- コンパイル段階での構成。
- 構成が冗長になる場合があります。
おわりに
このデモが、 JavaプログラマーがScala構文を理解し、 Scalaプログラマーが特定の言語コンストラクトをよく知られている抽象概念にマッピングできるようにすることで、2つの言語間のギャップを埋めることを願っています。