Go - from Google


Array e slices      

Eccoci ad un altro argomento fondamentale per questo linguaggio (e, a dire il vero, anche in altri). Nella prima parte di questo paragrafo parliamo degli array che, insieme agli slices, fanno parte delle cosiddette collections (o collezioni, se volete proprio usare il termine tradotto, ma francamente non ne vedo il motivo). Partiamo dagli array perchè le slices sono in un certo senso un derivato degli array stessi.

In Go gli array sono sequenze finite di lunghezza fissa di elementi aventi tutti uno stesso tipo base. Il tipo base può essere uno di quelli primitivi o anche definito dall'utente. La lunghezza dell'array è un intero che parte da zero e non ammette valori negativi; inoltre tale lunghezza, che ovviamente fa riferimento al numero di elementi presenti, fa parte del "tipo" array nel suo complesso così che un array di caratteri lungo 3 è diverso come tipologia rispetto ad un array di interi di 3 elementi ma anche rispetto ad un array di caratteri lungo 4. La lunghezza di un array non può essere indicata tramite una variabile. Gli elementi di un array sono caratterizzati da un indice intero univoco. L'indicizzazione è sequenziale e parte da 0, così che l'ultimo indice è pari al numero di elementi presenti meno uno. L'accesso al singolo elemento avviene per mezzo dell'operatore
[].
Abbiamo praticamente detto tutto manca però la definizione formale che può estrinsecarsi in 3 formati (ma non solo... vedremo poco più avanti):

(1) [N] tipo
(2) [N] tipo {valore1, valore2, .... , valoreN}
(3) [...] tipo {valore1, valore2, ... , valoreN}


Vediamo un esempio:

  Esempio 8.1
1
2
3
4
5
6
7
8
9
10
11
package main
import "fmt"

func main() {
var arr0 [5] int
arr1 := [...]int{1,2,3,4}
arr2 := [5] int {1,2,3,4,5}
fmt.Println(arr0[0])
fmt.Println(arr1[1])
fmt.Println(arr2[2])
}

Direi molto semplice, la definizione si ha alle righe 5, 6 e 7 mentre le 8, 9 e 10 specificano l'accesso ad un singolo elemento. Come detto gli indici iniziano da zero così per esempio arr1 definito nell'esempio precedente risulta:

elementi 1 2 3 4
indici 0 1 2 3

Peraltro l'inizializzazione vista al punto (2) non obbliga necessariamente ad inserire tutti gli N valori; per cui è possibile formalizzare anche così:

[N] tipo {valore1, valore2, ..., valore N-x)

come vedremo è prevista una inizializzazione di default.
E' anche possibile fare una cosa come quella seguente:

var arr2 = [5] int {3:2,4:7}

inizializzando specifici indici (nel caso il 3 ed 4) mentre gli altri saranno inizializzati di default.

Una osservazione importante è che in Go gli array sono dei valori, ovvero la loro definizione comprende l'intero array. Insomma non abbiamo, con questo tipo di definizione, come in C un puntatore al primo elemento ed un offset. Passare un array ad un funzione ad esempio passa un copia di tutti gli elementi (a meno che non si passi un puntatore ed a quel punto però si lavoro sul puntatore, quindi c'è da fare un po' di attenzione). La riga 8 con il suo output, ovvero il numero 0, ci indica anche che come avevamo anticipato, l'array arr1 ha i suoi elementi automaticamente inizializzati ad un valore di default, 0 appunto, valido per la tipologia di dati contenuta. Questa inizializzazione di default vale per ogni tipo di dato a cui appartengano gli elementi di un array. Le righe 8, 9 e 10 confermano ed esemplicano la possibilità di accesso al singolo elemento tramite il citato operatore [ ].

Altro operatore tipico e importante è quello che retituisce la lunghezza di un array; in Go questo operatore è len.

arr2 := [5] int {1,2,3,4,5}
fmt.Println(len(arr2))


restituisce 5 che è la lunghezza, ovvero il numero di elementi, di arr2.

La domanda successiva che si fa di solito a questo punto è come si fa a scorrere attraverso tutti gli elementi di un array? La risposta è banale: attraverso un ciclo for. Si può fare uso del formato "classico", simile a quanto si incontra in molti altri linguaggi e che si appoggia appunto su "len" oppure di una modalità più "Go oriented" (magari non è troppo vero ma possiamo dire così :-) ) che si appoggia sull'utilissimo e più sintetico range. Il seguente esempio mostra entrambe le modalità in azione, rispettivamente alla riga 6 ed alla 9:

  Esempio 8.2
1
2
3
4
5
6
7
8
9
10
11
12
package main
import "fmt"

func main() {
  arr1 := [5]int {1,2,3,4,5}
  for x := 0; x < len(arr1); x++ {
    fmt.Println(arr1[x])
  }
  for x := range arr1 {
    fmt.Println(arr1[x])
  }
}

Gli array creati così come abbiamo fatto fino adesso sono, come detto valori. Esiste però un modo alternativo di creare un array, solo apparentemente quasi identico ad uno dei sistemi precedenti:

1) var arr1 = new ([5]int)

Come specificato anche nella documentazione ufficiale la differenza tra questa scrittura e

2) var arr2 [5]int

è che nella 1) abbiamo un puntatore, non un valore. Quindi arr1 è di tipo *[5]int non [5]int

In condizioni normali, usando le definizioni tipo la 2) l'assegnazione di un array ad un altro lascia comunque la piena indipendenza. Vediamolo tramite un esempio:
 
  Esempio 8.3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main
import "fmt"

func main() {
  arr1 := [5]int {1,2,3,4,5}
  var arr2 = new( [5] int)
  arr2 := arr1
  arr2[0] = 100
  arr1[1] = 300
  for x := 0; x < len(arr1); x++ {
    fmt.Println(arr1[x])
  }
  for x := range arr2 {
  fmt.Println(arr2[x])
  }
}

Eseguendo questo semplice programma risulterà chiaro che i due una modifica che coinvolge uno dei due array lascia inalterato l'altro. Come è facile capire inoltre quando passiamo un array come argomento ad una funzione viene creata una copia dell'array stesso e quindi eventuali modifiche compiute nell'ambito della funzione non intaccano l'array originale. Diversamente, come visto in precedenza, bisogna passare attraverso i puntatori:

  Esempio 8.4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import "fmt"

func main() {
  var arr2 [5] int
  arr2[0] = 8
  fmt.Println(arr2[0])
  a(&arr2)
  fmt.Println(arr2[0])
}

func a (arr3 *[5]int) {
  arr3[0] = 100
}

Più in generale possiamo dire che esiste la via alternativa di rivolgerci ad un array tramite un puntatore allo stesso. Di seguito un esempio tratto dalle prime slides di presentazione del linguaggio, molto sintetico:

  Esempio 8.5
1
2
3
4
5
6
7
8
9
package main
import "fmt"

func fp(a *[3]int) { fmt.Println(a) }
func main() {
for i := 0; i < 3; i++ {
fp(&[3]int{i, i*i, i*i*i})
}
}

Gli array che abbiamo visto finora sono monodimensionali ma nulla vieta di definirli in modo da formare array multidimensionali:

var arr1 [5][5] int
arr2 := [2][2] int {{1,2},{3,4}}


Qui abbiamo ovviamente degli array 2 x 2 ma è possibile aumentare le dimensioni a piacere (francamente al momento non so se esista un limite). Gli array multidimensionali in Go sono sempre rettangolari quindi non è possibile definire array jagged come per esempio in C#; la cosa è ottenibile tramite gli slices, come vedremo più avanti. Ecco un esempio di array bidimensionale:

  Esempio 8.6
1
2
3
4
5
6
7
8
9
10
11
package main
import "fmt"

func main() {
  arr := [2][2] int {{1,2},{3,4}}
  for x := 0; x < 2; x++ {
    for y := 0; y < 2; y++ {
      fmt.Println(arr[x][y])
    }
  }
}


SLICES

Strettamente legati agli array (rispetto ai quali sono spesso preferiti dai programmatori Go sia per la maggior duttilità sia per la miglior efficienza) anche, come vedremo dal punto di vista formale, sono gli slices. Nota: esiste una traduzione coerente di 'sta parola? Slice in italiano è maschile, inteso come "pezzo" o "femminile", inteso come "porzione"? Nel dubbio mi tengo la dizione inglese e vado col maschile. Differentemente dagli array gli slices sono reference type. Si tratta di un riferimento ad un segmento di un array (chiamato array sottostante). Questo segmento può coincidere con l'intero array ed è comunque delimitato da un indice di partenza ed un indice finale (l'elemento all'indice finale non è compreso nella slice). E' importante sottolineare che la lunghezza di uno slice può variare, al contrario di quella di un array, da 0 fino ad arrivare alla lunghezza dell'array sottostante ed comunque modificabile dinamicamente. Le sue dimensioni sono quindi rilevabili a runtime invece che a compile time. Esiste una specifica funzione (cap) che dice quanto grande può diventare uno slice. La lunghezza di uno slice è variabile ma la sua capacità è, per quanto detto, fissa. Essendo così legati agli array anche gli slices possono contenere solo elementi di uno stesso tipo; inoltre più slices possono puntare ad uno stesso array. Questo significa che in caso di copia non viene creato un nuovo array, con conseguente occupazione di memoria, ma un puntatore alla medesima zona. Ovviamente non c'è indipendenza tra le due copie. Il dinamismo, la maggior leggerezza e facilità d'uso fanno quasi sempre preferire gli slices agli array puri. Un altro linguaggio che presenta un simile formato di dati è il D. Formalmente la definizione di uno slice può avvenire in 4 modi:

1) make([]Tipo, lunghezza, capacita)
2) make([]Tipo, lunghezza)
3) []Tipo{}
4) []Tipo{valore1, valore2, …, valoreN}


Una particolarità importante è che all'interno delle parentesi quadrate non è indicata la lunghezza. Uno slice può non essere inizializzata nel qual caso ha lunghezza 0 e valore nil.

Vediamo un esempio introduttivo:

  Esempio 8.7
1
2
3
4
5
6
7
8
9
10
11
package main
import "fmt"

func main() {
  var sl01 [] int
  sl02 := [] int {1,2,3,4,5}
  fmt.Println(len(sl01))
  fmt.Println(len(sl02))
  fmt.Println(cap(sl01))
  fmt.Println(cap(sl02))
}

Il legame che esiste tra slice ed array può essere illustrato come segue:

var s = []int {1,2,3,4,5}

ed ecco visivamente cosa abbiamo:



s punta all'array definito all'interno delle parentesi graffe portandosi dietro le sue definizioni cap e len. Si vede quindi che uno slice è sostanzialmente composto da 3 campi: il puntatore all'area di memoria in cui è definito l'array sottostante, il campo relativo alla lunghezza corrente e quello contenente la capacità massima. In pratica si può dire che una slice è così costruita:

type Slice struct {
base *elem_type;
len int;
cap int;
}

Di seguito vediamo ora un esempio che mette in evidenza il legame tra array sottostante e slice e anche cosa accade un presenza di due slices che derivano dal medesimo array:

  Esempio 8.8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main
import "fmt"

func main() {
arr1 := [5]int {1,2,3,4,5}
var sli1 []int = arr1[2:5]
fmt.Println(arr1)
fmt.Println(sli1)
fmt.Println(cap(sli1))
var sli2 []int = arr1[0:3]
fmt.Println(sli2)
arr1[2] = 9
fmt.Println(sli1)
fmt.Println(sli2)
sli1[0] = 40
fmt.Println(sli1)
fmt.Println(sli2)
}

Di particolare interesse sono le seguenti righe:
10: introduciamo un secondo sloice legato all'array arr1.
12: cambia un valore nell'array sottostante -> cambia il valore corrispondente negli slices
15: lo slice sli1 vede modificato un suo valore -> viene modificato il valore anche in sli2. Questo è naturale perchè sli1 è un puntatore quindi le modifiche avvengono laddove esso punta. Anche sli2 viene pertanto modificato in maniera identica a sli1.

Si capisce anche, in base a quanto detto, che per li slices valgono i seguenti formalismi: dato uno slice s è vero che

len(s) <= cap(s)
s == s[:i] + s[i:]

Proseguendo nella nostra analisi vediamo altre vie per lavorare con gli slices:

  Esempio 8.9
1
2
3
4
5
6
7
8
9
package main
import "fmt"

func main() {
var s []byte
s = make([]byte, 5, 7)
fmt.Println(len(s))
fmt.Println(cap(s))
}

La riga 6 utilizza il costrutto make per definire le proprietà di uno slice precedentemente dichiarato (riga 5; a quel punto il valore dello slice è nil mentre il suo cap e len valgono 0) e inizalmente vuoto. Come già detto in precedenza il primo parametro indica la lunghezza il secondo la capacità (se provare ad invertire il 5 col 7 alla riga 6 ne otterrete un errore a runtime). Questa definizione, per altro raramente usata, è molto elastica. Il seguente esempio estende il 8.9:

  Esempio 8.10
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main
import "fmt"

func main() {
var s []byte
s = make([]byte, 5, 7)
s[0] = 1
s[4] = 6
fmt.Println(len(s))
fmt.Println(cap(s))
fmt.Println(s)
arr1 := [3] byte {1,2,3}
s = arr1[1:2]
fmt.Println(s)
}

Come si può notare eseguendo il programma, lo slice s ha dapprima un certo contenuto, ovvero,

[1 0 0 0 6]

come esplicitato alla riga 11, che diventa poi

[2]

output della riga 14, a seguito della manipolazione avvenuta alla riga 13.
La definizione composta dalle righe 5 e 6 si può anche scrivere naturalmente come:

s := make([]byte, 5, 7) oppure
var s = make([]byte, 5, 7)


quindi utilizzabile quando l'array non è ancora definito.

E' possibile aumentare la capacità di una slice senza modificarne i contenuti. Per fare ciò non esiste alcuna "magia" bisogna definire un nuovo slice con le dimensioni volute e riversarvi dentro il contenuto del vecchio slice. Il seguente esempio mostra come fare in via esplicita e attraverso la funzione built-in copy:

  Esempio 8.11
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import "fmt"

func main() {
  arr1 := [5]int {1,2,3,4,5}
  var s1 []int = arr1[2:5]
  s2 := make([]int, len(s1), (cap(s1))*2)
  fmt.Println(len(s1))
  fmt.Println(cap(s1))
  fmt.Println(len(s2))
  fmt.Println(cap(s2))
  for i := range s1 {
    s2[i] = s1[i]
  }
  fmt.Println(s1)
  fmt.Println(s2)
  s3 := make([]int, len(s1), (cap(s1))*2)
  copy(s3,s1)
  fmt.Println(s3)
  s1 = s3
  fmt.Println(len(s1))
  fmt.Println(cap(s1))
}

La riga 7 introduce uno slice che capacità doppia rispetto alla capacità di quello originale. Dalla 12 alla 14 abbiamo un loop che copia tutto gli elementi da s1 ad s2. La riga 17 presenta un altro slice, uguale ad s2, la copia viene effettuata alla riga 18 tramite il preannunciato "copy" che evidentemente rende più semplice la vita. Alla riga 20 abbiamo l'allineamento di s1 con s3 in termini di capacità. Ovviamente se la capacità dello slice fosse 0 le istruzioni alla riga 7 e 17 dovrebbero usare una formula tipo
(cap(s1) + 1) * 2
altrimenti la lunghezza resta 0, naturalmente.

Prima di proseguire vediamo una tabella che schematizza le modalità per legare un array ed una slice da un punto di vista formale:

s[n]     Indica l'elemento all'indice n
s[n:m]   Slice costruita dall'indice n all'indice m-1
s[n:]    Slice costruita dall'indice n a len(s) - 1
s[:m]    Slice costruita dall'indce a m-1
s[:]     Slice costruita da 0 ad n-1


Gli slices possono essere usati facilmente come parametri di una funzione:; l'esempio che segue, in cui viene applicata l'espressione 5 appena introdotta, è tratto dall'eccellente testo "A way to go" e mi pare il più chiaro e semplice possibile, con un minimo di senso, per cui lo riporto pari pari.

  Esempio 8.12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main
import "fmt"

func sum(sl0 []int) int {
  temp := 0
  for x := 0; x < len(sl0); x++ {
    temp = temp + sl0[x]
  }
  return temp
}

func main() {
  var arr = [5]int{1,2,3,4,5}
  fmt.Println(sum(arr[:]))
}

Direi che l'esempio si commenta da solo. Evidentemente si poteva usare un array anche alla riga 4 ma utilizzare lo slice rende il tutto molto più efficiente. L'esempio mostra anche, tramite il ciclo for alla riga 6, un esempio di ciclo compiuto su tutti gli elementi dello slice.

E' possibile, come nel caso degli array, definire degli slices multidimensionali; di passaggio possiamo notare come la variabilità di lunghezza degli slices permetta di risolvere una piccola questione accennata in precedenza ovvero quella di avere degli array jagged; ad esempio tramite un array di slices (o una slice di slices). Formalmente la cosa è simile a quanto visto per gli array.

Un'altra funzione built-in molto comoda è append. Come è evidente dal nome stesso essa permette di appendere uno o più elementi ad uno slice. Per accodare uno slice ad un altro dovremo invece ricorrere all'operatore ... (ellipsi).
La funzione append è internamente così definita:

func append(s []T, x ...T) []T

e risulta di facile utilizzo:

  Esempio 8.13
1
2
3
4
5
6
7
8
package main
import "fmt"

func main() {
s := []int{1,2,3,4,5}
s = append(s,6,7,8)
fmt.Println(s)
}

che fornisce come output:

[1 2 3 4 5 6 7 8]

Quando la funzione append porta lo slice oltre la propria capacità questa viene raddoppiata. Provate ad esplicitarla inserendo elementi in eccesso rispetto alla capacità. Come avrete capito viene creata, in maniera silente, una nuova slice con le caratteristiche adatte. Come detto è possibile anche appendere uno slice ad un altro:

  Esempio 8.14
1
2
3
4
5
6
7
8
9
package main
import "fmt"

func main() {
  s := []int{1,2,3,4,5}
  t := []int{6,7,8}
  s = append(s, t...)
  fmt.Println(s)
}


Ma non basta: gli slices sono manipolabili anche in termini di riduzione dei propri elementi. E' possibile cancellare elementi dalla testa, dalla coda e all'interno di uno slice. La cosa è banale nei primi due casi, un po' più complesso, in apparenza, nell'ultimo.

Definiamo quindi uno slice;

(1) s := []int{1,2,3,4,5,6,7,8}

s = s[1:]
elimina il primo elemento.

s = s[:len(s)-1]
elimina l'ultimo elemento

Per eliminare uno o più elementi interni invece si può ricorrere, paradossalmente, al nostro append. Riferendoci sempre al nostro slice (1) definito qui sopra:

s = append(s[:1], s[5:]...)
elimina i numeri 2,3,4,5

potete sbizzarrirvi con gli esempi cosa che è bene fare anche perchè non è difficile incappare in banali errori.

Il discorso sugli slices è ancora ampio ed offre interessanti opportunità. Ad esempio è possibile effettuarne il sort quando gli elementi contenuti siano interi, float64 oppure stringhe. Per fare questo tuttavia è necessario importare un package specifico, chiamato appunto "sort". Vediamo l'esempio:

  Esempio 8.15
1
2
3
4
5
6
7
8
9
package main
import "fmt"
import "sort"

func main() {
  s := []string{"aa", "abc", "cc", "ba"}
  sort.Strings(s)
  fmt.Println(s)
}

Il package sort offre parecchie altre funzioni che, se riusciremo, vedremo altrove.