Che cos'è "Pattern Matching" nei linguaggi funzionali?


Risposte:


142

La comprensione della corrispondenza del modello richiede la spiegazione di tre parti:

  1. Tipi di dati algebrici.
  2. Che cos'è il pattern matching
  3. Perché è fantastico.

Tipi di dati algebrici in breve

Linguaggi funzionali di tipo ML consentono di definire tipi di dati semplici chiamati "unioni disgiunte" o "tipi di dati algebrici". Queste strutture di dati sono semplici contenitori e possono essere definite in modo ricorsivo. Per esempio:

type 'a list =
    | Nil
    | Cons of 'a * 'a list

definisce una struttura di dati simile a uno stack. Pensalo equivalente a questo C #:

public abstract class List<T>
{
    public class Nil : List<T> { }
    public class Cons : List<T>
    {
        public readonly T Item1;
        public readonly List<T> Item2;
        public Cons(T item1, List<T> item2)
        {
            this.Item1 = item1;
            this.Item2 = item2;
        }
    }
}

Quindi, gli identificatori Conse Nildefiniscono semplice una classe semplice, dove of x * y * z * ...definisce un costruttore e alcuni tipi di dati. I parametri per il costruttore sono senza nome, sono identificati dalla posizione e dal tipo di dati.

Crei istanze della tua a listclasse in quanto tale:

let x = Cons(1, Cons(2, Cons(3, Cons(4, Nil))))

Che è lo stesso di:

Stack<int> x = new Cons(1, new Cons(2, new Cons(3, new Cons(4, new Nil()))));

Combinazione di motivi in ​​breve

La corrispondenza dei modelli è una specie di prova del tipo. Quindi diciamo che abbiamo creato un oggetto stack come quello sopra, possiamo implementare metodi per sbirciare e far apparire lo stack come segue:

let peek s =
    match s with
    | Cons(hd, tl) -> hd
    | Nil -> failwith "Empty stack"

let pop s =
    match s with
    | Cons(hd, tl) -> tl
    | Nil -> failwith "Empty stack"

I metodi sopra indicati sono equivalenti (sebbene non implementati come tali) al seguente C #:

public static T Peek<T>(Stack<T> s)
{
    if (s is Stack<T>.Cons)
    {
        T hd = ((Stack<T>.Cons)s).Item1;
        Stack<T> tl = ((Stack<T>.Cons)s).Item2;
        return hd;
    }
    else if (s is Stack<T>.Nil)
        throw new Exception("Empty stack");
    else
        throw new MatchFailureException();
}

public static Stack<T> Pop<T>(Stack<T> s)
{
    if (s is Stack<T>.Cons)
    {
        T hd = ((Stack<T>.Cons)s).Item1;
        Stack<T> tl = ((Stack<T>.Cons)s).Item2;
        return tl;
    }
    else if (s is Stack<T>.Nil)
        throw new Exception("Empty stack");
    else
        throw new MatchFailureException();
}

(Quasi sempre, i linguaggi ML implementano la corrispondenza dei modelli senza test di tipo o cast di runtime, quindi il codice C # è in qualche modo ingannevole. Spazzoliamo i dettagli dell'implementazione a parte agitando la mano per favore :))

Decomposizione della struttura dei dati in breve

Ok, torniamo al metodo peek:

let peek s =
    match s with
    | Cons(hd, tl) -> hd
    | Nil -> failwith "Empty stack"

Il trucco è capire che gli identificatori hde tlsono variabili (errm ... dato che sono immutabili, non sono realmente "variabili", ma "valori";)). Se sha il tipo Cons, estrarremo i suoi valori dal costruttore e li assoceremo alle variabili denominate hde tl.

La corrispondenza dei modelli è utile perché ci consente di scomporre una struttura di dati in base alla sua forma anziché al suo contenuto . Quindi immagina se definiamo un albero binario come segue:

type 'a tree =
    | Node of 'a tree * 'a * 'a tree
    | Nil

Possiamo definire alcune rotazioni dell'albero come segue:

let rotateLeft = function
    | Node(a, p, Node(b, q, c)) -> Node(Node(a, p, b), q, c)
    | x -> x

let rotateRight = function
    | Node(Node(a, p, b), q, c) -> Node(a, p, Node(b, q, c))
    | x -> x

(Il let rotateRight = functioncostruttore è lo zucchero di sintassi per let rotateRight s = match s with ....)

Quindi oltre a associare la struttura dei dati alle variabili, possiamo anche approfondire. Diciamo che abbiamo un nodo let x = Node(Nil, 1, Nil). Se chiamiamo rotateLeft x, testiamo xcontro il primo modello, che non corrisponde perché il figlio giusto ha il tipo Nilanziché Node. Passerà al modello successivo x -> x, che corrisponderà a qualsiasi input e lo restituirà non modificato.

Per fare un confronto, scriveremmo i metodi sopra in C # come:

public abstract class Tree<T>
{
    public abstract U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc);

    public class Nil : Tree<T>
    {
        public override U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc)
        {
            return nilFunc();
        }
    }

    public class Node : Tree<T>
    {
        readonly Tree<T> Left;
        readonly T Value;
        readonly Tree<T> Right;

        public Node(Tree<T> left, T value, Tree<T> right)
        {
            this.Left = left;
            this.Value = value;
            this.Right = right;
        }

        public override U Match<U>(Func<U> nilFunc, Func<Tree<T>, T, Tree<T>, U> nodeFunc)
        {
            return nodeFunc(Left, Value, Right);
        }
    }

    public static Tree<T> RotateLeft(Tree<T> t)
    {
        return t.Match(
            () => t,
            (l, x, r) => r.Match(
                () => t,
                (rl, rx, rr) => new Node(new Node(l, x, rl), rx, rr))));
    }

    public static Tree<T> RotateRight(Tree<T> t)
    {
        return t.Match(
            () => t,
            (l, x, r) => l.Match(
                () => t,
                (ll, lx, lr) => new Node(ll, lx, new Node(lr, x, r))));
    }
}

Per seriamente.

La corrispondenza dei motivi è fantastica

Puoi implementare qualcosa di simile al pattern matching in C # usando il pattern visitatore , ma non è altrettanto flessibile perché non puoi scomporre efficacemente strutture dati complesse. Inoltre, se stai usando la corrispondenza dei modelli, il compilatore ti dirà se hai lasciato un caso . Quanto è fantastico?

Pensa a come implementeresti funzionalità simili in C # o linguaggi senza pattern matching. Pensa a come lo faresti senza test-test e cast in fase di esecuzione. Non è certamente difficile , solo ingombrante e voluminoso. E non hai il compilatore che controlla per assicurarti di aver coperto ogni caso.

Pertanto, la corrispondenza dei modelli ti aiuta a scomporre e navigare le strutture dei dati in una sintassi molto comoda e compatta, consente al compilatore di controllare la logica del tuo codice, almeno un po '. E 'davvero è una caratteristica killer.


+1 ma non dimenticare altre lingue con pattern matching come Mathematica.
JD

1
"ERRm ... dato che sono immutabili, non sono realmente 'variabili', ma 'valori';)" Loro sono variabili; è la varietà mutevole che è etichettata erroneamente . Tuttavia, ottima risposta!
Doval,

3
"Quasi sempre, i linguaggi ML implementano la corrispondenza dei modelli senza test o cast di runtime" <- Come funziona? Puoi indicarmi un primer?
David Moles,

1
@DavidMoles: il sistema dei tipi consente di eludere tutti i controlli di runtime dimostrando che le corrispondenze dei pattern sono esaustive e non ridondanti. Se si tenta di alimentare una lingua come SML, OCaml o F # una corrispondenza di pattern non esaustiva o contenente ridondanza, il compilatore ti avviserà al momento della compilazione. Questa è una funzionalità estremamente potente perché ti consente di eliminare i controlli di runtime riorganizzando il tuo codice, cioè puoi avere aspetti del tuo codice dimostrati corretti. Inoltre, è facile da capire!
JD,

@JonHarrop Posso vedere come funzionerebbe (in effetti è simile all'invio di messaggi dinamici) ma non riesco a vedere come in fase di runtime selezioni un ramo senza un test di tipo.
David Moles,

33

Risposta breve: la corrispondenza dei modelli nasce dal fatto che i linguaggi funzionali trattano il segno di uguale come un'affermazione di equivalenza anziché di assegnazione.

Risposta lunga: la corrispondenza dei modelli è una forma di invio basata sulla "forma" del valore che viene dato. In un linguaggio funzionale, i tipi di dati definiti sono in genere quelli che sono noti come sindacati discriminati o tipi di dati algebrici. Ad esempio, cos'è un elenco (collegato)? Un elenco collegato Listdi cose di qualche tipo aè la lista vuota Nilo qualche elemento di tipo a Consed ed a List a(un elenco di as). In Haskell (il linguaggio funzionale che conosco di più), scriviamo questo

data List a = Nil
            | Cons a (List a)

Tutti i sindacati discriminati sono definiti in questo modo: un singolo tipo ha un numero fisso di modi diversi per crearlo; i creatori, come Nile Consqui, sono chiamati costruttori. Ciò significa che un valore del tipo List aavrebbe potuto essere creato con due costruttori diversi: potrebbe avere due forme diverse. Supponiamo quindi di voler scrivere una headfunzione per ottenere il primo elemento dell'elenco. In Haskell, lo scriveremmo come

-- `head` is a function from a `List a` to an `a`.
head :: List a -> a
-- An empty list has no first item, so we raise an error.
head Nil        = error "empty list"
-- If we are given a `Cons`, we only want the first part; that's the list's head.
head (Cons h _) = h

Poiché i List avalori possono essere di due tipi diversi, è necessario gestirli separatamente; questa è la corrispondenza del modello. In head x, se xcorrisponde al modello Nil, eseguiamo il primo caso; se corrisponde allo schema Cons h _, eseguiamo il secondo.

Risposta breve, spiegata: penso che uno dei modi migliori per pensare a questo comportamento sia quello di cambiare il modo in cui pensi al segno uguale. Nelle lingue tra parentesi graffe, in generale, =indica il compito: a = bsignifica "fare ain b". In molti linguaggi funzionali, tuttavia, =denota un'affermazione di uguaglianza: let Cons a (Cons b Nil) = frob x afferma che la cosa a sinistra Cons a (Cons b Nil), è equivalente alla cosa a destra frob x; inoltre, tutte le variabili utilizzate a sinistra diventano visibili. Questo è anche ciò che sta accadendo con gli argomenti delle funzioni: affermiamo che il primo argomento è simile Nile, in caso contrario, continuiamo a controllare.


Che modo interessante di pensare al segno uguale. Grazie per averlo condiviso!
jrahhali,

2
Cosa Conssignifica?
Roymunson,

2
@Roymunson: Consè il cons tructor che costruisce una lista (collegata) da una testa (la a) e una coda (la List a). Il nome deriva da Lisp. In Haskell, per il tipo di elenco incorporato, è l' :operatore (che è ancora pronunciato "contro").
Antal Spector-Zabusky,

23

Significa che invece di scrivere

double f(int x, int y) {
  if (y == 0) {
    if (x == 0)
      return NaN;
    else if (x > 0)
      return Infinity;
    else
      return -Infinity;
  } else
     return (double)x / y;
}

Tu puoi scrivere

f(0, 0) = NaN;
f(x, 0) | x > 0 = Infinity;
        | else  = -Infinity;
f(x, y) = (double)x / y;

Ehi, C ++ supporta anche la corrispondenza dei pattern.

static const int PositiveInfinity = -1;
static const int NegativeInfinity = -2;
static const int NaN = -3;

template <int x, int y> struct Divide {
  enum { value = x / y };
};
template <bool x_gt_0> struct aux { enum { value = PositiveInfinity }; };
template <> struct aux<false> { enum { value = NegativeInfinity }; };
template <int x> struct Divide<x, 0> {
  enum { value = aux<(x>0)>::value };
};
template <> struct Divide<0, 0> {
  enum { value = NaN };
};

#include <cstdio>

int main () {
    printf("%d %d %d %d\n", Divide<7,2>::value, Divide<1,0>::value, Divide<0,0>::value, Divide<-1,0>::value);
    return 0;
};

1
In Scala: import Double._ def divide = {valori: (Double, Double) => valori corrispondono a {case (0,0) => NaN case (x, 0) => if (x> 0) PositiveInfinity else NegativeInfinity case (x, y) => x / y}}
fracca,

12

La corrispondenza dei modelli è una specie di metodo sovraccarico sugli steroidi. Il caso più semplice sarebbe più o meno lo stesso di quello che hai visto in Java, gli argomenti sono un elenco di tipi con nomi. Il metodo corretto da chiamare si basa sugli argomenti passati e funge anche da assegnazione di tali argomenti al nome del parametro.

I pattern fanno solo un passo avanti e possono destrutturare ulteriormente gli argomenti passati. Può anche potenzialmente utilizzare le protezioni per abbinare effettivamente in base al valore dell'argomento. Per dimostrare, farò finta che JavaScript avesse una corrispondenza del modello.

function foo(a,b,c){} //no pattern matching, just a list of arguments

function foo2([a],{prop1:d,prop2:e}, 35){} //invented pattern matching in JavaScript

In foo2, si aspetta che a sia un array, rompe il secondo argomento, si aspetta un oggetto con due oggetti di scena (prop1, prop2) e assegna i valori di tali proprietà alle variabili d ed e, quindi si aspetta che il terzo argomento sia 35.

A differenza di JavaScript, le lingue con la corrispondenza dei motivi in ​​genere consentono più funzioni con lo stesso nome, ma con motivi diversi. In questo modo è come sovraccarico di metodo. Faccio un esempio in erlang:

fibo(0) -> 0 ;
fibo(1) -> 1 ;
fibo(N) when N > 0 -> fibo(N-1) + fibo(N-2) .

Sfoca un po 'gli occhi e puoi immaginarlo in javascript. Qualcosa del genere forse:

function fibo(0){return 0;}
function fibo(1){return 1;}
function fibo(N) when N > 0 {return fibo(N-1) + fibo(N-2);}

Fai notare che quando chiami fibo, l'implementazione che utilizza si basa sugli argomenti, ma dove Java è limitato ai tipi come unico mezzo di sovraccarico, la corrispondenza dei modelli può fare di più.

Oltre al sovraccarico delle funzioni come mostrato qui, lo stesso principio può essere applicato in altri luoghi, come dichiarazioni di casi o asserzioni distruttive. JavaScript ha anche questo in 1.7 .


8

La corrispondenza dei motivi consente di abbinare un valore (o un oggetto) a determinati motivi per selezionare un ramo del codice. Dal punto di vista del C ++, può sembrare un po 'simile switchall'affermazione. Nei linguaggi funzionali, la corrispondenza dei modelli può essere utilizzata per la corrispondenza su valori primitivi standard come numeri interi. Tuttavia, è più utile per i tipi composti.

Innanzitutto, dimostriamo la corrispondenza dei modelli sui valori primitivi (usando pseudo-C ++ esteso switch):

switch(num) {
  case 1: 
    // runs this when num == 1
  case n when n > 10: 
    // runs this when num > 10
  case _: 
    // runs this for all other cases (underscore means 'match all')
}

Il secondo uso riguarda tipi di dati funzionali come le tuple (che consentono di memorizzare più oggetti in un singolo valore) e i sindacati discriminati che consentono di creare un tipo che può contenere una delle diverse opzioni. Questo suona un po 'come enumse non fosse che ogni etichetta può contenere anche alcuni valori. In una sintassi pseudo-C ++:

enum Shape { 
  Rectangle of { int left, int top, int width, int height }
  Circle of { int x, int y, int radius }
}

Un valore di tipo Shapeora può contenere o Rectanglecon tutte le coordinate o a Circlecon il centro e il raggio. La corrispondenza dei motivi ti consente di scrivere una funzione per lavorare con il Shapetipo:

switch(shape) { 
  case Rectangle(l, t, w, h): 
    // declares variables l, t, w, h and assigns properties
    // of the rectangle value to the new variables
  case Circle(x, y, r):
    // this branch is run for circles (properties are assigned to variables)
}

Infine, puoi anche utilizzare modelli nidificati che combinano entrambe le funzionalità. Ad esempio, è possibile utilizzare la Circle(0, 0, radius)corrispondenza per tutte le forme che hanno il centro nel punto [0, 0] e hanno un raggio (il valore del raggio verrà assegnato alla nuova variabile radius).

Questo può sembrare un po 'sconosciuto dal punto di vista del C ++, ma spero che il mio pseudo-C ++ chiarisca la spiegazione. La programmazione funzionale si basa su concetti abbastanza diversi, quindi ha un senso migliore in un linguaggio funzionale!


5

La corrispondenza del modello è il punto in cui l'interprete per la tua lingua sceglierà una funzione particolare in base alla struttura e al contenuto degli argomenti forniti.

Non è solo una funzione di linguaggio funzionale ma è disponibile per molte lingue diverse.

La prima volta che mi sono imbattuto nell'idea è stato quando ho imparato il prologo in cui è veramente centrale nella lingua.

per esempio

last ([LastItem], LastItem).

last ([Head | Tail], LastItem): - last (Tail, LastItem).

Il codice precedente fornirà l'ultimo elemento di un elenco. L'input arg è il primo e il risultato è il secondo.

Se nell'elenco è presente un solo elemento, l'interprete sceglierà la prima versione e il secondo argomento verrà impostato in modo che sia uguale al primo, ovvero verrà assegnato un valore al risultato.

Se l'elenco ha sia una testa che una coda, l'interprete sceglierà la seconda versione e si ripeterà fino a quando non rimarrà un solo elemento nell'elenco.


Inoltre, come puoi vedere dall'esempio, l'interprete può anche suddividere automaticamente un singolo argomento in più variabili (ad es. [Head | Tail])
charlieb

4

Per molte persone, prendere un nuovo concetto è più semplice se vengono forniti alcuni semplici esempi, quindi eccoci qui:

Supponiamo che tu abbia un elenco di tre numeri interi e desideri aggiungere il primo e il terzo elemento. Senza pattern matching, potresti farlo in questo modo (esempi in Haskell):

Prelude> let is = [1,2,3]
Prelude> head is + is !! 2
4

Ora, sebbene questo sia un esempio giocattolo, immagina che vorremmo associare il primo e il terzo numero intero alle variabili e sommarle:

addFirstAndThird is =
    let first = head is
        third = is !! 3
    in first + third

Questa estrazione di valori da una struttura di dati è ciò che fa la corrispondenza dei modelli. Fondamentalmente "rispecchi" la struttura di qualcosa, dando variabili per legare i luoghi di interesse:

addFirstAndThird [first,_,third] = first + third

Quando si chiama questa funzione con [1,2,3] come argomento, [1,2,3] verrà unificato con [primo _, terzo], legando prima a 1, terza a 3 e scartando 2 ( _è un segnaposto per cose che non ti interessano).

Ora, se vuoi solo abbinare le liste con 2 come secondo elemento, puoi farlo in questo modo:

addFirstAndThird [first,2,third] = first + third

Funzionerà solo per gli elenchi con 2 come secondo elemento e genererà un'eccezione altrimenti, poiché non viene fornita alcuna definizione per addFirstAndThird per gli elenchi non corrispondenti.

Fino ad ora, abbiamo usato il pattern matching solo per legare destrutturando. Inoltre, puoi fornire più definizioni della stessa funzione, in cui viene utilizzata la prima definizione di corrispondenza, quindi la corrispondenza del modello è un po 'come "un'istruzione switch su stereoidi":

addFirstAndThird [first,2,third] = first + third
addFirstAndThird _ = 0

addFirstAndThird aggiungerà felicemente il primo e il terzo elemento degli elenchi con 2 come secondo elemento, altrimenti "fall through" e "return" 0. Questa funzionalità "simile a un interruttore" non può essere utilizzata solo nelle definizioni delle funzioni, ad esempio:

Prelude> case [1,3,3] of [a,2,c] -> a+c; _ -> 0
0
Prelude> case [1,2,3] of [a,2,c] -> a+c; _ -> 0
4

Inoltre, non è limitato agli elenchi, ma può essere utilizzato anche con altri tipi, ad esempio abbinando i costruttori di valori Just e Nothing del tipo Maybe per "scartare" il valore:

Prelude> case (Just 1) of (Just x) -> succ x; Nothing -> 0
2
Prelude> case Nothing of (Just x) -> succ x; Nothing -> 0
0

Certo, quelli erano semplici esempi di giocattoli, e non ho nemmeno provato a dare una spiegazione formale o esauriente, ma dovrebbero bastare per comprendere il concetto di base.


3

Dovresti iniziare con la pagina di Wikipedia che dà una spiegazione abbastanza buona. Quindi, leggi il capitolo pertinente del wikibook di Haskell .

Questa è una bella definizione dal wiki precedente:

Quindi la corrispondenza dei modelli è un modo per assegnare nomi alle cose (o legare quei nomi a quelle cose), e possibilmente scomporre espressioni in sottoespressioni allo stesso tempo (come abbiamo fatto con l'elenco nella definizione di mappa).


3
La prossima volta menzionerò in questione che ho già letto Wikipedia e mi dà una pessima spiegazione.
Roman

2

Ecco un esempio molto breve che mostra l'utilità di corrispondenza dei pattern:

Diciamo che vuoi ordinare un elemento in un elenco:

["Venice","Paris","New York","Amsterdam"] 

a (ho ordinato "New York")

["Venice","New York","Paris","Amsterdam"] 

in una lingua più imperativa scriveresti:

function up(city, cities){  
    for(var i = 0; i < cities.length; i++){
        if(cities[i] === city && i > 0){
            var prev = cities[i-1];
            cities[i-1] = city;
            cities[i] = prev;
        }
    }
    return cities;
}

In un linguaggio funzionale dovresti invece scrivere:

let up list value =  
    match list with
        | [] -> []
        | previous::current::tail when current = value ->  current::previous::tail
        | current::tail -> current::(up tail value)

Come puoi vedere la soluzione abbinata al modello ha meno rumore, puoi chiaramente vedere quali sono i diversi casi e quanto sia facile viaggiare e destrutturare il nostro elenco.

Ho scritto un post sul blog più dettagliato al riguardo qui .

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.