Interpreter Design Pattern in Java

1. Panoramica

In questo tutorial, introdurremo uno dei modelli di progettazione comportamentali di GoF: l'interprete.

In un primo momento, daremo una panoramica del suo scopo e spiegheremo il problema che cerca di risolvere.

Quindi, daremo uno sguardo al diagramma UML di Interpreter e all'implementazione dell'esempio pratico.

2. Interpreter Design Pattern

In breve, il modello definisce la grammatica di una particolare lingua in un modo orientato agli oggetti che può essere valutato dall'interprete stesso.

Tenendo questo a mente, tecnicamente potremmo costruire la nostra espressione regolare personalizzata, un interprete DSL personalizzato o potremmo analizzare uno qualsiasi dei linguaggi umani, costruire alberi di sintassi astratti e quindi eseguire l'interpretazione.

Questi sono solo alcuni dei potenziali casi d'uso, ma se ci pensiamo un po ', potremmo trovarne ancora di più, ad esempio nei nostri IDE, poiché interpretano continuamente il codice che stiamo scrivendo e quindi ci forniscono suggerimenti impagabili.

Il pattern interprete generalmente dovrebbe essere usato quando la grammatica è relativamente semplice.

Altrimenti, potrebbe diventare difficile da mantenere.

3. Diagramma UML

Il diagramma sopra mostra due entità principali: il contesto e l' espressione .

Ora, qualsiasi lingua deve essere espressa in qualche modo e le parole (espressioni) avranno un significato basato sul contesto dato.

AbstractExpression definisce un metodo astratto che accetta il contestocome parametro. Grazie a ciò, ogni espressione influenzerà il contesto , cambierà il suo stato e continuerà l'interpretazione o restituirà il risultato stesso.

Pertanto, il contesto sarà il titolare dello stato di elaborazione globale e verrà riutilizzato durante l'intero processo di interpretazione.

Allora qual è la differenza tra TerminalExpression e NonTerminalExpression ?

Una NonTerminalExpression può avere una o più altre AbstractExpressioni associate, quindi può essere interpretata ricorsivamente. Alla fine, il processo di interpretazione deve terminare con una TerminalExpression che restituirà il risultato.

Vale la pena notare che NonTerminalExpression è un composto.

Infine, il ruolo del cliente è quello di creare o utilizzare un albero di sintassi astratto già creato , che non è altro che una frase definita nel linguaggio creato.

4. Implementazione

Per mostrare il pattern in azione, costruiremo una semplice sintassi simile a SQL in modo orientato agli oggetti, che verrà poi interpretata e ci restituirà il risultato.

Per prima cosa, definiremo le espressioni Select, From e Where , costruiremo un albero della sintassi nella classe del client ed eseguiremo l'interpretazione.

L' interfaccia Expression avrà il metodo interpret:

List interpret(Context ctx);

Successivamente, definiamo la prima espressione, la classe Select :

class Select implements Expression { private String column; private From from; // constructor @Override public List interpret(Context ctx) { ctx.setColumn(column); return from.interpret(ctx); } }

Ottiene il nome della colonna da selezionare e un'altra espressione concreta di tipo From come parametri nel costruttore.

Si noti che nel metodo interpret () sostituito imposta lo stato del contesto e passa l'interpretazione ulteriormente a un'altra espressione insieme al contesto.

In questo modo, vediamo che è un NonTerminalExpression.

Un'altra espressione è la classe From :

class From implements Expression { private String table; private Where where; // constructors @Override public List interpret(Context ctx) { ctx.setTable(table); if (where == null) { return ctx.search(); } return where.interpret(ctx); } }

Ora, in SQL la clausola where è facoltativa, quindi questa classe è un'espressione terminale o non terminale.

Se l'utente decide di non utilizzare una clausola where, l' espressione From verrà terminata con la chiamata ctx.search () e restituirà il risultato. Altrimenti, sarà ulteriormente interpretato.

L' espressione Where modifica nuovamente il contesto impostando il filtro necessario e termina l'interpretazione con la chiamata di ricerca:

class Where implements Expression { private Predicate filter; // constructor @Override public List interpret(Context ctx) { ctx.setFilter(filter); return ctx.search(); } }

Per l'esempio, la classe Context contiene i dati che imitano la tabella del database.

Nota che ha tre campi chiave che vengono modificati da ciascuna sottoclasse di Expression e dal metodo di ricerca:

class Context { private static Map
    
      tables = new HashMap(); static { List list = new ArrayList(); list.add(new Row("John", "Doe")); list.add(new Row("Jan", "Kowalski")); list.add(new Row("Dominic", "Doom")); tables.put("people", list); } private String table; private String column; private Predicate whereFilter; // ... List search() { List result = tables.entrySet() .stream() .filter(entry -> entry.getKey().equalsIgnoreCase(table)) .flatMap(entry -> Stream.of(entry.getValue())) .flatMap(Collection::stream) .map(Row::toString) .flatMap(columnMapper) .filter(whereFilter) .collect(Collectors.toList()); clear(); return result; } }
    

Al termine della ricerca, il contesto si cancella automaticamente, quindi la colonna, la tabella e il filtro vengono impostati sui valori predefiniti.

In questo modo ogni interpretazione non influirà sull'altra.

5. Test

A scopo di test, diamo un'occhiata alla classe InterpreterDemo :

public class InterpreterDemo { public static void main(String[] args) { Expression query = new Select("name", new From("people")); Context ctx = new Context(); List result = query.interpret(ctx); System.out.println(result); Expression query2 = new Select("*", new From("people")); List result2 = query2.interpret(ctx); System.out.println(result2); Expression query3 = new Select("name", new From("people", new Where(name -> name.toLowerCase().startsWith("d")))); List result3 = query3.interpret(ctx); System.out.println(result3); } }

Per prima cosa, costruiamo un albero della sintassi con le espressioni create, inizializziamo il contesto e quindi eseguiamo l'interpretazione. Il contesto viene riutilizzato, ma come abbiamo mostrato sopra, si pulisce da solo dopo ogni chiamata di ricerca.

Eseguendo il programma, l'output dovrebbe essere il seguente:

[John, Jan, Dominic] [John Doe, Jan Kowalski, Dominic Doom] [Dominic]

6. Aspetti negativi

Quando la grammatica diventa più complessa, diventa più difficile da mantenere.

Può essere visto nell'esempio presentato. Sarebbe ragionevolmente facile aggiungere un'altra espressione, come Limite , ma non sarebbe troppo facile da mantenere se decidessimo di continuare ad estenderla con tutte le altre espressioni.

7. Conclusione

Il modello di progettazione dell'interprete è ottimo per un'interpretazione grammaticale relativamente semplice , che non ha bisogno di evolversi ed estendersi molto.

Nell'esempio sopra, abbiamo mostrato che è possibile costruire una query simile a SQL in modo orientato agli oggetti con l'aiuto del pattern interprete.

Infine, è possibile trovare questo utilizzo del modello in JDK, in particolare in java.util.Pattern , java.text.Format o java.text.Normalizer .

Come al solito, il codice completo è disponibile nel progetto Github.