Guida String interning - Part 1

driverfury

Utente Electrum
26 Agosto 2015
326
14
147
150
Ultima modifica:

String Interning: Introduzione

Ciao a tutti, oggi volevo fare una piccola guida sullo string interning ovvero un metodo che ci permette di memorizzare una sola copia di ogni stringa distinta.

Per capire meglio il conetto, procediamo con un esempio concreto.

Consideriamo il seguente prototipo di funzione:
C:
char *strintern(char *s);

Vogliamo che quando chiamiamo la funzione strintern ci venga ritornato un puntatore ad un'altra stringa uguale memorizzata in un nostro "database".

Ad esempio, se scriviamo:
C:
char *str1 = strintern("ciao");

Viene chiamata la funzione strintern con argomento "ciao", ovvero viene memorizzata una copia di quel "ciao" all'interno di un nostro "database" (che vedremo, sarà una linked list). Viene restituito il puntatore al "ciao" che abbiamo memorizzato. Di consiguenza str1 punterà ad esso.

Se, successivamente, scriviamo:
C:
char *str2 = strintern("ciao");

Viene chiamata di nuovo la funzione strintern che cercherà all'interno del "database" per verificare se ci sia già la stringa "ciao". La trova, perché l'abbiamo aggiunta prima. E restituisce il puntatore al "ciao" che già esiste.

Di conseguenza, avremo che str1 sarà uguale a str2.

Ovvero, avremo il vantaggio di comparare due stringhe "interned" semplicemente tramite una comparazione tra puntatori ovvero:
C:
str1 == str2

In questo modo non occorre confrontare carattere per carattere per verificare se due stringhe sono uguali.

Inoltre, come vedremo, avremo il vantaggio di non calcolare ogni volta la lunghezza di una stringa, ovvero non ci sarà bisogno di contare i caratteri della stringa.

Interfaccia


L'interfaccia del modulo che vogliamo sviluppare consiste in due sole funzioni, di conseguenza questo sarà il file strintern.h:

C:
extern char *strintern(char *s);
extern unsigned int strinternlen(char *s);

La prima è quella che, data in input una strina, cerca nel database se esiste una copia identica:
  • se esiste, restituisce il puntatore alla copia memorizzata nel database
  • se non esiste, crea prima una copia uguale nel database e poi ne restituisce il puntatore ad essa

La seconda, invece, ci restituisce la lunghezza (ovvero il numero di caratteri fino al terminatore, quest'ultimo escluso) di una stringa che è già stata internata.

Perché fare questa funzione se esiste già lo strlen della libreria standard C?

Perché come vedremo, sarà una funzione più rapida che non avrà bisogno di contare carattere per carattere.

Questa è l'interfaccia, molto semplice. L'implementazione che vedremo, invece, sarà leggermente più complicata.

Implementazione Test Driven


Vogliamo implementare le due funzioni di sopra. Bene. Vogliamo seguire, però, un approccio test-driven, ovvero vogliamo scrivere prima il codice del test dell'implementazione e successivamente l'implementazione stessa.

In poche parole, vogliamo che quando chiamiamo la seguente funzione, non vi sia alcun errore:

C:
#include <assert.h>
#include "strintern.h"

void test_strintern(void)
{
        assert(strintern(0) == 0);
        assert(strintern("") == 0);
        assert(strinternlen(strintern(0)) == 0);
        assert(strintern("ciao") != 0);
        assert(strintern("ciao") == strintern("ciao"));
        assert(strinternlen(strintern("ciao")) == 4);
        assert(strintern("ciao a tutti") == strintern("ciao a tutti"));
        assert(strintern("ciao a tutti") != strintern("ciao"));
        assert(strinternlen(strintern("ciao a tutti")) == 12);
}

Ovviamente, si può ottimizzare questo codice, ma per il momento lo lasciamo così, è solo un codice di test scritto di getto.

Abbiamo scritto, quindi, la funzione test_strintern che, quando chiamata, ci dice se e quale assert ha fallito. Se nessun assert fallisce vuol dire che la nostra implementazione è corretta.

Bene, procediamo con l'implementazione.

Prima di tutto abbiamo bisogno di un nostro "database" in cui creare e cercare le stringhe interned. Per il momento va bene una lista concatenata (linked list). Magari in un'altra guida spiegherò come ottimizzare, perché le linked list sono molto lenta per la ricerca.

Abbiamo bisogno quindi della seguente struct:

C:
typedef struct strinterned strinterned;

struct strinterned
{
       strinterned *next;
       unsigned int len;
       char str[1];
};

Abbiamo i seguenti campi:
  • next che punta alla prossima stringa interned (roba standard delle liste concatenate)
  • len che contiene la lunghezza della stringa, accederemo a questo campo nella funzione strinternlen anziché contare i caratteri e per questo avremo prestazioni migliori
  • str[1] è un array che di default contiene un solo carattere ma che, in pratica, conterrà len + 1 caratteri (il +1 è per il carattere terminatore '\0').

Ci serve quindi il nostro database, ovvero la linked list. E anche due funzioni che ci permettono di creare una strinterned e di aggiungerla alla linked list.

C:
/* Questo è il nostro database. Per il momento è una linked list, ma occorre "trasformarla"
 * in qualcosa di più performante perché la ricerca all'interno delle linked list può
 * risultare molto lenta. */
static strinterned *_strinterns;

#include <stdlib.h> /* For malloc() */
#include <string.h> /* For strlen() and strcpy() */

static strinterned *make_strintern(char *s)
{
       strinterned *new_strintern;
       unsigned int s_len = strlen(s);
       new_strintern = (strinterned *)malloc(sizeof(strinterned) + s_len);
       new_strintern->next = 0;
       new_strintern->len = s_len;
       strcpy(new_strintern->str, s);
       new_strintern->str[s_len] = '\0';
       return new_strintern;
}

static void add_strintern(strinterned *new_strintern)
{
       /* Se la linked list è ancora vuota, il primo elemento sarà new_strintern */
       if(!_strinterns)
       {
              _strinterns = new_strintern;
       }
       /* Se la linked list non è vuota, aggiungi il nuovo elemento in coda */
       else
       {
              strinterned *s = _strinterns;
              while(s->next) s = s->next;
              s->next = new_strintern;
       }
}

La funzione make_strintern crea dinamicamente una strinterned, mentre la funzione add_strintern aggiunge un elemento in coda alla linked list _strinterns.

Procediamo ora con l'implementazione dei due prototipi dichiarati nell'interfaccia.

Iniziamo prima con strinternlen che è la più semplice:

C:
extern unsigned int strinternlen(char *s)
{
       if(!s) return 0;
       strinterned *tmp = (strinterned *)(s - offsetof(strinterned, str));
       return tmp->len;
}

Ovvero, sappiamo che il campo str[1] si trova alla fine della struct strinterned, quindi andiamo indietro di offsetof(strinterned, str) ed arriviamo al primo campo, quindi abbiamo il nostro puntatore strinterned *tmp che punta all'elemento della linked list. Da lì è semplice recupare la lunghezza della stringa, basta accedere al campo tmp->len.

Quindi, anziché contare i caratteri, abbiamo restituito il campo che conteneva già il valore della lunghezza della stringa.

Ora passiamo al cuore di questa mini-libreria di string interning, ovvero alla funzione strintern:

C:
#include <string.h> /* For strcmp() */

extern char *strintern(char *s)
{
       if(!s) return 0;
       if(!*s) return 0;

       /* Cerchiamo nel database per verificare se già esiste una copia di s */
       int found = 0;
       strinterned *ptr = _strinterns;
       while(!found && ptr)
       {
             found = (strcmp(ptr->str, s) == 0);
             if(!found)
             {
                    ptr = ptr->next;
             }
       }

       /* Se non è stata trovata una copia, la si crea e la si aggiunge al database */
       if(!found)
       {
             ptr = make_strintern(s);
             add_strintern(ptr);
       }

       /* Ritorna la stringa copiata o creata */
       return (char *)ptr->str;
}

Sviluppi futuri

Intanto mi scuso di eventuali bug o errori di battitura, anche se ho testato e sembra essere tutto ok.

Questa libreria può essere ottimizzata utilizzando l'hashing ovvero una metodologia che ci permette di cercare all'interno del nostro "database" di stringhe più rapidamente e quindi rendere la funzione strintern più rapida.

Fatemi sapere se questa guida è stata abbastanza chiara, interessante, ecc... e se volete che prosegua scrivendo una guida sull'hashing in modo da ottimizzare questa libreria.
 
Ultima modifica da un moderatore:
Idea buona (e' un classico) MA si puo' fare di meglio.

1) l'hasing non e' il solo metodo per migliorare l'efficienza, anche una strttura ad albero binario o un trie andrebbe bene.

Questa parte di implementazione andrebbe COMUNQUE inserita se vuoi rendere il tuo codice ""usabile"".

Non ha senso se hai SOLO 10 stringhe (ma in questo case, non servirebbe nemmeno la funzione).
Questa tecnica e' utile quando devi maniplare MIGLIAIA/MILIONI di stringhe simili: gia' con 100 stringhe, invece di 50 test ne fai solo 7!
Con 1000 stringhe, solo 10 ... con 1000000 SOLO 20, ecc
O anche meno, se usi una funzione hash ed un'implentazione basata su bucket fatta in modo intelligente.

2) 0 (zero) e' un modo per rappresentare il puntatore NULL in C.

Il puntatore NULL

NON E' EQUIVALENTE

alla stringa VUOTA, cioe' di lunghezza ZERO ""

Il puntatore NULL NON E' una stringa valida.

La stringa VUOTA E' una stringa VALIDA!


Conseguenza, il puntatore NULL NON DEVE MAI ESSERE CONFUSO con un vettore (di qualsiasi tipo) di lunghezza ZERO!!!

Per fare le cose per bene, se chiami la tua funzione con NULL, la funzione dovrebbe generare un'errore.

Nel 99.99999% dei casi un NULL e' un errore di programmazione fatto da qualche parte.
 
  • Mi piace
Reazioni: driverfury
Idea buona (e' un classico) MA si puo' fare di meglio.

1) l'hasing non e' il solo metodo per migliorare l'efficienza, anche una strttura ad albero binario o un trie andrebbe bene.

Questa parte di implementazione andrebbe COMUNQUE inserita se vuoi rendere il tuo codice ""usabile"".

Non ha senso se hai SOLO 10 stringhe (ma in questo case, non servirebbe nemmeno la funzione).
Questa tecnica e' utile quando devi maniplare MIGLIAIA/MILIONI di stringhe simili: gia' con 100 stringhe, invece di 50 test ne fai solo 7!
Con 1000 stringhe, solo 10 ... con 1000000 SOLO 20, ecc
O anche meno, se usi una funzione hash ed un'implentazione basata su bucket fatta in modo intelligente.

2) 0 (zero) e' un modo per rappresentare il puntatore NULL in C.

Il puntatore NULL

NON E' EQUIVALENTE

alla stringa VUOTA, cioe' di lunghezza ZERO ""

Il puntatore NULL NON E' una stringa valida.

La stringa VUOTA E' una stringa VALIDA!


Conseguenza, il puntatore NULL NON DEVE MAI ESSERE CONFUSO con un vettore (di qualsiasi tipo) di lunghezza ZERO!!!

Per fare le cose per bene, se chiami la tua funzione con NULL, la funzione dovrebbe generare un'errore.

Nel 99.99999% dei casi un NULL e' un errore di programmazione fatto da qualche parte.

Grazie mille per la risposta. Fa sempre piacere parlare con gente che ne sa più di te. Infatti non avevo mai pensato alle strutture ad albero per questo tipo di casistica.

Riguardo alle puntualizzazioni...

Premetto che ho copincollato la maggior parte del codice da un altro mio progetto di tempo fa, quindi se qualcosa non è propriamente corretta al 100% fatemela passare ahah.

1) Grazie mille per lo spunto sulle strutture ad albero.

2) So benissimo che il puntatore NULL non è equivalente alla costante 0, perché NULL è una macro definita come:
C:
#define NULL ((void*)0)

Tuttavia lo standard C89/C90 (che è quello che conosco bene e che utilizzo) dice che per inizializzare un puntatore a null o assegnare il valore null a un puntatore che già esiste, deve essere utilizzata una null pointer constant che è ((void*)0) o 0.

Infatti è specificato che:

An integer constant expression with the value 0, or such an expression cast to type void *, is called a null pointer constant.

Di conseguenza, non è stato fatto nessun errore riguardo i puntatori nulli (sinceramente mi capita raramente il fatto che un errore sia dovuto a un NULL da qualche parte).

Per quanto riguarda la stringa vuota, so benissimo che non è un puntatore nullo ma è una stringa "valida". Infatti è un puntatore ad una cella di memoria che contiene '\0' e di conseguenza non è un puntatore nullo.

Tuttavia, la funzione strintern prende come argomento un char * che può essere anche un puntatore nullo ed ho voluto trattarlo come fosse una stringa vuota (ripeto, codice preso da un'altro progetto, in QUEL CASO, ma anche in molti altri casi, va bene così).
 
  • Grazie
Reazioni: DanyDollaro
Non contesto l'utilizzo di 0 al posto di NULL.

Diciamo che ""sarebbe meglio"" usare "NULL" invece di 0 per rendere il codice ""piu' coerente"" (0 e' un intero, NULL e un puntatore di tipo void) e per compatibilita' con "nullptr" del C++.

Se proponi un post di tipo ""insegnamento"", meglio fare le cose in modo ""formale"", senza usare ""trucchi da programmatore"", in modo che il ""ziovine"" programmatore impari a programmare in modo ""corretto"" ;-)

Dopo di che ci sono i ""trucchi trucchettosi da programmatore/smanettone"" che usiamo TUTTI ;-)
Ma questi sono un'altra storia ;-)
 
Non contesto l'utilizzo di 0 al posto di NULL.

Diciamo che ""sarebbe meglio"" usare "NULL" invece di 0 per rendere il codice ""piu' coerente"" (0 e' un intero, NULL e un puntatore di tipo void) e per compatibilita' con "nullptr" del C++.

Se proponi un post di tipo ""insegnamento"", meglio fare le cose in modo ""formale"", senza usare ""trucchi da programmatore"", in modo che il ""ziovine"" programmatore impari a programmare in modo ""corretto"" ;-)

Dopo di che ci sono i ""trucchi trucchettosi da programmatore/smanettone"" che usiamo TUTTI ;-)
Ma questi sono un'altra storia ;-)
Non conosco il C++, conosco soltanto il linguaggio C così come definito nello standard C89.

Quindi, purtroppo, non so nulla riguardo alla compatibilità nullptr del C++.

Semplicemente ho scritto un programma C nello standard C89, a parte per i commenti che in C89 esistono solo quelli così: /* commento */. Anzi dopo se ricordo, li cambio.

Non ho usato nessun trucco.

E lo standard C89 non dice che “sarebbe meglio” usare NULL anziché 0, dice soltanto che sono equivalenti quando si vuole assegnare il valore null ad un puntatore.

Nessun trucco.

Just C.
 
nullptr non e' necessariamente 0 anche se il compare con 0 e' garantito. Ma e' C++ e nulla centra con C. In C, NULL, 0 sono stessa identica cosa.

In genere vado ancora piu breve di == 0 o == NULL, utilizzo

Codice:
char *p = (char *)malloc(1024);
if (!p)
      error_handler();
 
  • Mi piace
Reazioni: driverfury