Esiste una differenza funzionale tra un metodo e una funzione che viene passato come riferimento a una struct?

6

In nessuna lingua particolare:

  class fooclass
  {
    int A, B, C, D;
    public int GetItemSum()
    {
      int sum = this.A + this.B + this.C;
      this.D = sum;
      return this.A + this.B + this.C;
    }
  }

rispetto a:

  struct foostruct
  {
    public int A, B, C, D;
  }

  int GetItemSum(foostruct ref fs)
  {
    int sum = fs.A + fs.B + fs.C;
    fs.D = sum;
    return sum;
  }

C'è una differenza più profonda rispetto alla sintassi e alla leggibilità? OOP è essenzialmente un insieme di gruppi di funzioni in cui il riferimento all'oggetto è sempre un argomento?

Il motivo per cui lo chiedo è perché mi sono imbattuto in un codice C che è stato scritto in questo modo (essenzialmente usando Structs come classi), e sto ancora facendo fatica a capire se c'è un vantaggio nel farlo in quel modo invece che semplicemente usando C ++.

    
posta Ahmed 26.06.2017 - 22:13
fonte

4 risposte

8

Questo dipende molto dalla tua definizione di OOP. Se l'OOP per te riguarda solo l'incapsulamento e la modularizzazione, non c'è una vera differenza tra i tuoi esempi. Ma ci sono altre interpretazioni:

  • OOP riguarda il "passaggio di messaggi"
  • Gli oggetti
  • sono la combinazione di dati + comportamento
  • OOP parla di "sottotipo polimorfismo"

Questi significano cose leggermente diverse, ma hanno un punto in comune: quando usi un oggetto, non hai bisogno di sapere come è implementato. In molte lingue, questa astrazione è gestita tramite dispatch dinamico .

Qui abbiamo un consumatore del tuo oggetto:

DoSomething(foo object) {
  Print(object.GetItemSum());
}

Poiché si tratta di una chiamata al metodo, è responsabilità dell'oggetto scoprire come ottenere una somma di un articolo. Cioè diciamo all'oggetto cosa fare. Ciò significa che il comportamento può essere sovrascritto in una sottoclasse foo. Al contrario, se avessimo usato una chiamata di funzione ordinaria GetItemSum(object) , avremmo detto esattamente come dovrebbe essere calcolata la somma dell'oggetto - esattamente da quella funzione. Poiché il chiamante decide quale funzione usare, una sottoclasse foo non può ignorare questo comportamento.

Il dispatch dinamico può essere implementato inserendo puntatori di funzione nelle implementazioni del metodo nell'oggetto. Per i linguaggi OOP basati sulla classe, i puntatori di funzione non si trovano direttamente nell'oggetto, ma in una tabella dei metodi condivisa da tutti gli oggetti. Questa tabella è anche chiamata vtable (tabella dei metodi virtuali).

Quindi usando pseudo-C / C ++ come linguaggio, possiamo simulare una classe in stile Java come questa:

struct foo_vtable {
  int (*GetItemSum)(foo* this);
};

foo_vtable foo_class = { .GetItemSum = foo_GetItemSum };

struct foo {
  foo_vtable* class;
  int A, B, C D;
};

foo* foo_new() {
  foo* this = malloc(sizeof(foo));
  this->class = &foo_class;
  foo_init(this);
  return this;
}

void foo_init() {
  this->A = ...;
  ...
}


int foo_GetItemSum(foo* this) {
  ...
}

La chiamata al metodo virtuale instance.getItemSum() viene quindi effettivamente compilata come:

instance->class->GetItemSum(instance);

Quella riga precedente è fondamentalmente l'intera magia di OOP. Chiamare un metodo indirettamente come questo è chiamato "invio dinamico" o "chiamata metodo virtuale".

Ora possiamo definire una classe bar che eredita da foo :

struct bar_vtable : foo_vtable {
  // may or may not have extra methods
};

bar_vtable = { .GetItemSum = bar_GetItemSum };

struct bar : foo {
  // may or may not have extra fields
};

bar* bar_new() {
  bar* this = malloc(sizeof(bar));
  this->class = &bar_class;
  bar_init(this);
  return this;
}

void bar_init(bar* this) {
  foo_init(this);
  ...
}

int bar_GetItemSum(foo* this) {
  ...
}

Ora se l'istanza viene creata con bar_new() , l'istanza utilizzerà un'implementazione di metodo diversa rispetto alle istanze foo. Tuttavia, non è necessario aggiornare alcun codice che utilizzi istanze foo tramite il meccanismo di invio dinamico: a causa dell'invio dinamico, verrà invece eseguito il metodo sottoclasse! L'istanza stessa sa quale metodo usare.

(In un'altra risposta, ho scritto un esempio più semplice di dispacciamento dinamico basato sulla classe che viene effettivamente eseguito in C, ma non copre l'inizializzazione dell'istanza. Nel mio blog, ho un articolo sulle differenze tra le dinamiche e dispatch statico che include un piccolo esempio C ++ eseguibile che illustra queste differenze, senza entrare nei dettagli di implementazione.)

Quindi qual è il punto di tutto questo codice extra?

  • Il libro Design Patterns contiene molti esempi su come le tecniche OOP (= dynamic dispatch) possono essere utilizzate per implementare soluzioni semplici a problemi di progettazione ricorrenti. Nessuno di questi funziona se chiami direttamente una funzione, hai bisogno di invio dinamico.

  • Un'interfaccia è solo un vtable senza fornire implementazioni. Ogni volta che utilizzi un'interfaccia nel tuo codice, stai richiedendo la spedizione dinamica. Ad esempio, ciò consente l'iniezione di dipendenza o test di unità più semplici tramite oggetti fittizi.

risposta data 26.06.2017 - 23:01
fonte
5

Non ci sono differenze funzionali. Quando definisci un metodo per far parte di una classe, ciò che realmente accade è che il metodo è collocato da qualche parte in una memoria prendendo effettivamente l'istanza di classe che è stata definita come uno dei suoi parametri.

vale a dire. nel codice macchina non importa. Ma dove importa è durante lo sviluppo. OOP è diventato popolare perché ha reso la definizione dei limiti di contesto molto più chiara, attraverso le classi. Le unità logiche sono state raggruppate in classi che contengono non solo proprietà comuni ma anche metodi comuni che operano sulle proprietà.

Quale approccio è meglio è davvero difficile da dire. Nei linguaggi funzionali si preferisce il secondo modo, con le funzioni che trasformano l'input in output, in linguaggi come C # o Java è più probabile che si veda il primo approccio. Poi ci sono altri gruppi di sviluppatori, come ad esempio l'evangelista del design guidato dal dominio, che ti direbbe che il secondo esempio è un modello di dominio anemico ed è inutile.

Piuttosto che cercare di capire cosa è meglio, è meglio scegliere uno stile ed essere coerenti. La coerenza è prevedibile.

    
risposta data 26.06.2017 - 22:22
fonte
4

Il polimorfismo . Puoi fare in modo che lo stesso nome della funzione si comporti in modi diversi a seconda della struttura inviata come parametro?

Questa è fondamentalmente la differenza tra OO e funzioni semplici.

Quindi, in C non è possibile avere il polimorfismo con funzioni semplici che ricevono strutture, ma è possibile averlo in C ++ definendo i metodi nelle classi.

Tuttavia, alcuni linguaggi funzionali consentono alle funzioni di avere comportamenti diversi a seconda del tipo di argomenti (ad esempio Clojure), quindi in questo caso in particolare, sarebbero equivalenti.

    
risposta data 26.06.2017 - 22:23
fonte
2

A livello di implementazione, ciò che descrivi è all'incirca quello che succede nei linguaggi OOP. la risposta di amon va in questo in modo più dettagliato. Se non ti interessa dei tipi, allora è fondamentalmente la storia completa. Questo fa parte del motivo per cui ci sono cento e un sistema di oggetti in Scheme, ed è facile eseguire il roll-out in linguaggi tipizzati dinamicamente che hanno alcune nozioni di funzioni di prima classe o anche di puntatori di funzioni di seconda classe.

Quando prendi in considerazione il controllo dei tipi, le cose diventano un po 'più complicate e cominciano a comparire distinzioni abbastanza forti. Come semplice esempio, diciamo che hai una classe con alcune variabili di istanza "private". Se lo fai in una struct in C, dove intendi inserire le variabili private? Se li metti nella struct, allora non sono veramente privati e non puoi passare qualche altra struct con variabili private diverse ma la stessa interfaccia pubblica a funzioni che accettano il tuo tipo di struct. L'alternativa è avere un void * che punta allo stato privato dell'oggetto. Questo è brutto, ma il meglio che puoi fare e ciò che è effettivamente fatto in C. Quello che in realtà vorrai fare è usare un tipo esistenziale. Qualcosa come:

struct Foo<S> { // For efficiency reason's, you'd want an approach like amon's
    S *internalState;
    int A, B, C, D;
    int (*GetItemSum)(Foo<S> *this);
}

typedef (exists S.Foo<S>) Foo; // Roughly, Java's Foo<?>

Se la tua lingua ha funzioni di ordine superiore, allora questo esistenziale è già associato alla nozione di tipo di funzione, e possiamo evitare anche la bruttezza del campo internalState . Possiamo solo fare:

struct Foo {
    int A, B, C, D;
    Func<Foo *, int> *GetItemSum;
}

Ma questo ha ancora un problema. Se creo una struct Bar che soddisfi la stessa interfaccia ma forse abbia qualcosa in più, una situazione simile all'ereditarietà, GetItemSum deve ancora essere una Foo accepting function, non un Bar che accetta uno. In realtà, quello che vorremmo dire è che il primo parametro di GetItemSum deve corrispondere al tipo di struct che lo contiene. Il costrutto che fa questo è chiamato self-types. Una forma semplice di esso è essenzialmente incorporata nei linguaggi OO e il termine di solito si riferisce a forme più elaborate. Ci sono un paio di modi per evitare questo problema. Innanzitutto, potremmo spostare i metodi dalla struct come nel codice della domanda originale, ma questo sposta semplicemente il problema e lo rende così che non è possibile sovrascrivere i metodi. In alternativa, potremmo semplicemente avere GetItemSum vicino al parametro this , ovvero avere Foo essere:

struct Foo {
    int A, B, C, D;
    Func<int> *GetItemSum;
}

Questo funziona finché non si desidera eseguire la sottoclasse, ovvero l'ereditarietà dell'implementazione. Se Foo aveva un altro metodo, ad esempio PreProcess , che era stato invocato da GetItemSum , questo approccio avrebbe portato al seguente problema. Se ho creato una sottoclasse Bar che ha superato PreProcess , che è stata sostituita con la propria copia, ma altrimenti delegata alle implementazioni di Foo , il metodo GetItemSum non chiamerebbe Bar 's PreProcess ma Foo . Questo è il motivo per cui la "ricorsione" aperta si presenta spesso quando si parla di lingue OO. L'ultima codifica, in cui GetItemSum non richiede parametri, richiede al "costruttore" di definire ricorsivamente Foo in termini di se stesso. Per consentire ai metodi di chiamare metodi che potrebbero essere sovrascritti in una sottoclasse, questa ricorsione deve rimanere aperta.

Ad ogni modo, le codifiche di conservazione del tipo di programmazione orientata agli oggetti in, tipicamente, i calcoli lambda calcolati sono stati studiati abbastanza pesantemente negli anni '90. La maggior parte della complessità è stata causata da cose come sottoclassi e sovrascrittura del metodo. Le versioni complete di queste codifiche erano in genere troppo pesanti per essere utilizzate seriamente. Le codifiche (anche alcune di quelle più semplici) illustrano quanta complessità è nascosta dietro le nozioni "di base" di OO.

È normale che la gente parli di come sia "facile" codificare OO in, per esempio, linguaggi funzionali, ma tendono a o) a ignorare i tipi, oppure b) utilizzare una nozione molto semplificata di OO. La buona notizia, tuttavia, è che molte delle funzionalità di codifica più difficili sono oggi meno popolari, come l'ereditarietà dell'implementazione. Vi sono anche dei vantaggi nel suddividere le nozioni OO in concetti più semplici poiché alcuni problemi, come il problema del metodo binario, sono più facili da gestire data la flessibilità extra di avere i pezzi che costituiscono la nozione di "classe".

    
risposta data 27.06.2017 - 01:33
fonte

Leggi altre domande sui tag