Sei sulla pagina 1di 37

UNIVERSITÀ DEGLI STUDI DI TORINO

Dipartimento di Informatica

Scuola di Scienze della Natura

Corso di laurea triennale in Informatica

Tesi di laurea triennale in informatica

Il linguaggio Go: sviluppo di una


Chat

Relatore: Candidato:
Prof. Gian Luca Pozzato Yassine Amri

Anno accademico 2021/2022


Abstract
La sottostante relazione di Stage ha come obiettivo quello di introdurre
i concetti del linguaggio Go. Per far ciò è stato deciso di sviluppare un
progetto in modo da applicare gran parte delle caratteristiche e peculiarità
di Go e soprattutto per dare al lettore un contesto in grado di fornire delle
problematiche su cui ragionare.
Inoltre, sono stati creati anche vari codici di benchmark col fine di
ottenere una sorta di comparazione tra Go e Java che, per alcuni aspetti,
viene identificato come uno dei linguaggi con il quale viene messo spesso
a confronto

1
DICHIARAZIONE DI ORIGINALITÀ
“Dichiaro di essere responsabile del contenuto dell’elaborato che presento al
fine del conseguimento del titolo, di non avere plagiato in tutto o in parte il la-
voro prodotto da altri e di aver citato le fonti originali in modo congruente alle
normative vigenti in materia di plagio e di diritto d’autore. Sono inoltre con-
sapevole che nel caso la mia dichiarazione risultasse mendace, potrei incorrere
nelle sanzioni previste dalla legge e la mia ammissione alla prova finale potrebbe
essere negata.”

2
Alla mia famiglia
Contents
1 Introduzione 5
1.1 Go . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.2 Chat e Test . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.3 Obiettivi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5

2 Linguaggio Go 6
2.1 Dichiarazioni . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.1.1 Variabili . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
2.1.2 Puntatori . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2.1.3 Strutture . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2.2 Funzioni e Metodi . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.2.1 Funzioni . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.2.2 Metodi . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.3 Interfacce . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.4 Goroutine . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
2.5 Channel . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 12
2.6 Controllo del flusso . . . . . . . . . . . . . . . . . . . . . . . . . . 13
2.6.1 Defer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13
2.6.2 Panic . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
2.6.3 Recover . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14

3 Caratteristiche principali di Go 16
3.1 Compilazione . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3.1.1 Compilazione . . . . . . . . . . . . . . . . . . . . . . . . . 16
3.1.2 Compilazione incrociata . . . . . . . . . . . . . . . . . . . 17
3.2 Concorrenza . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3.2.1 Concorrenza e Parallelismo . . . . . . . . . . . . . . . . . 18
3.2.2 Goroutine vs Thread . . . . . . . . . . . . . . . . . . . . . 19
3.2.3 Sincronizzazione e Comunicazione . . . . . . . . . . . . . 20
3.2.4 Prestazioni . . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.3 OOP . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
3.3.1 Incapsulamento . . . . . . . . . . . . . . . . . . . . . . . . 26
3.3.2 Ereditarietà e Composizione . . . . . . . . . . . . . . . . . 27
3.3.3 Polimorfismo . . . . . . . . . . . . . . . . . . . . . . . . . 28
3.3.4 Astrazione . . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.4 Garbage Collection . . . . . . . . . . . . . . . . . . . . . . . . . . 31
3.5 Dipendenze . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
3.6 Meno è esponenzialmente meglio . . . . . . . . . . . . . . . . . . 33

4 Conclusioni 34

4
1 Introduzione
1.1 Go
Go è un linguaggio di programmazione open source sviluppato da Google e
supportato da un grande numero di contributori.
Viene anche chiamato Golang ed è un linguaggio ispirato a C, tipizzato
in modo statico e dotato di una compilazione veloce. È stato sviluppato per
velocizzare e semplificare la scrittura di programmi altamente concorrenti e
scalabili. Difatti viene usato per lo sviluppo di applicazioni server, applicazioni
distribuiti, middleware e database.
Go viene concepito nel 2007 da alcuni sviluppatori Google: Robert Griese-
mer, Rob Pike e Ken Thompson. Solo nel 2012 viene rilasciata ufficialmente
con la versione 1.0. Ad oggi, maggio 2022, siamo alla versione 1.18. Le funzion-
alità iniziali sono state progressivamente migliorate e ne sono state introdotte
delle nuove come recentemente è accaduto con i tipi generici. Queste modi-
fiche sono rese possibili da Google, in grado di garantire supporto e longevità
al linguaggio, e dalla community di Gophers che risulta essere una delle com-
munity più attive e propense a trovare problematiche e condividere soluzioni e
implementazioni.

1.2 Chat e Test


Per lo sviluppo della tesi è stato deciso di creare un progetto e vari test di
benchmark scritti interamente in Go in grado di coprire in gran parte tutte
le caratteristiche peculiari del linguaggio e soprattutto per dare al lettore un
contesto in grado di fornire delle problematiche su cui ragionare. Il progetto
consiste in un sistema di Chat che consta di due parti: Chat-Server e Chat-client.
Dunque è possibile creare messaggi e condividerli pubblicamente o all’interno di
una stanza in cui vi sono acceduti i vari utenti.

1.3 Obiettivi
Nel capitolo 2 viene introdotta una breve documentazione in grado di dare le
nozioni principali di Go, in modo da permettere la comprensione degli argomenti
successivi.
Nel capitolo 3 vengono mostrate le caratteristiche principali di Go, ma con
un’ottica di paragone sopratutto riguardo allo stile di programmazione, alla
performance di compilazione e al modello concorrenziale che è in grado di offrire.

5
2 Linguaggio Go
2.1 Dichiarazioni
2.1.1 Variabili
La variabile è una porzione di memoria adibita alla memorizzazione di un valore
di un tipo accessibile tramite un identificatore o nome. Esistono diversi modi
per dichiarare una variabile. La più comune è la dichiarazione di una singola
variabile:

var numberRooms int

Questa istruzione dichiara una variabile numberRooms tramite la keyword var,


indicando solo alla fine il tipo. Una delle caratteristica di Go è che tutte le
dichiarazione hanno la definizione del tipo alla fine dell’istruzione. Inoltre se ad
una variabile non viene assegnato nessun valore, questa viene inizializzata con
il valore zero del tipo del valore, che nel caso di numberRooms sara 0 dato che
si tratta di un int. In modo analogo, il valore zero di un tipo bool è false e per
il tipo string è una stringa vuota.
Il secondo modo è la dichiarazione per inferenza: in fase di dichiarazione, se
alla variabile viene assegnato un valore, verrà automaticamente dedotto il tipo
senza dover quindi indicarne uno alla fine dell’istruzione. Si consideri l’esempio
successivo:

var numberRooms = 0

In questo caso numberRooms viene identificato come tipo int


Il terzo modo è la dichiarazione a mano corta che avviene tramite l’operatore
di assegnamento :=. Questo operatore di assegnamento viene usato unicamente
di fase di dichiarazione. Anche in questo caso il tipo viene omesso in quanto è
una dichiarazione per inferenza:

numberMessages := 0

L’ultimo modo è la dichiarazione multipla che si ottiene usando la keyword


var e tra parentesi tutte le variabili che si vogliono dichiarare ed eventualmente
inizializzare:

var (
numberUsers int
numberMessages = 0
)

(1)(2)

6
2.1.2 Puntatori
Un puntatore è una variabile in grado di memorizzare la locazione degli ele-
menti in memoria. Per dichiarare un puntatore basta semplicemente utilizzare
l’operatore * prima del tipo. Così facendo potrà puntare solo a porzioni di
memoria che contengono valori dello stesso tipo.

b := 5
var a *int = &b

Il valore zero di un puntatore è il valore nullo, ovvero nil. Invece per dereferen-
ziare un puntatore, cioè accedere al valore della variabile puntata, è necessario
mettere l’operatore * prima del nome del puntatore. In sua mancanza si ottiene
l’indirizzo di memoria del valore puntato:

fmt.Println("l’indirizzo di b: ", a)
fmt.Println("il valore di b: ", *a)

Ovviamente c’è la possibilità di passare a parametro un puntatore a una


funzione oppure di ritornarne uno:

func (p *PublicMessage) Sender() *User {


return p.sender
}

Quello che invece non si può fare è usare l’aritmetica dei puntatori in quanto
Go non la supporta. (1)(2)

2.1.3 Strutture
Una struct è un costrutto che ci permette di definire un nuovo tipo di dato e che
aggrega al suo interno una raccolta di variabili. Può essere comunemente utiliz-
zato in momenti in cui ha senso raggruppare i dati in una singola unità anziché
avere ciascuno di essi come valori separati, come ad esempio la creazione di una
entità logica (nome della struttura) avente determinate caratteristiche (variabili
di cui è composta). In questo caso viene creata una struttura PublicMessage
che rappresenta l’entità “messaggio pubblico”:

type PublicMessage struct {


id uuid.UUID
body string
sender *User
sentAt time.Time
}

Così facendo è possibile creare una variabile di tipo PublicMessage e inizializ-


zarla inserendo i valori nell’ordine in cui sono state definite. Per accedere a un
valore è necessario utilizzare il nome della struttura, l’operatore punto . e infine

7
il campo a cui si vuole accedere.

publicMessage := {uuid.New(), "ciao", NewUser(), time.CurrentTime()}


fmt.Print(publicMessage.body)

Il valore zero di una struttura, ovvero quando non avviene assegnato alcun
valore ai campi che la compongono, è rappresentato dai valori zero di ciascun
campo in base al proprio tipo di riferimento. (1)(2)

2.2 Funzioni e Metodi


2.2.1 Funzioni
Una funzione è un blocco di codice in grado di eseguire un’attività distinta.
Inoltre può prendere in input i parametri in grado di permettere il calcolo per
l’ottenimento dell’output desiderato. La dichiarazione di una funzione inizia
con la keyword func, seguita dal nome della funzione, i parametri ed infine dal
tipo di ritorno qualora ce ne fosse. Anche per le funzioni, come per le variabili,
bisogna indicare il tipo alla fine.

func NewUser(username string) *User {


return &User{Username: username}
}

Questo codice di esempio rappresenta una funzione di nome NewUser che prende
come parametro la stringa username e ritorna un puntatore a un tipo User.
Una peculiarità interessante di Go è la possibilità di ritornare più di ele-
mento.

func Handle(packet packet.Packet, user *entity.User) (*Response, string)


{
err := joinToRoomUseCase.Handle(
usecase.NewJoinToRoomRequest(user.Username,
packet.Body["name"])
)

if err != nil {
return nil, "cannot handle this packet"
}

return NewPacketResponse(), ""


}

In questo esempio stiamo gestendo l’entrata da parte di un utente all’interno


di una stanza in cui scambiarsi i messaggi con altri utenti. Alla fine della
dichiarazione della funzione è presente una parentesi che contiene tutti i tipi dei
valori che verranno ritornati. In questo caso, se l’entrata dell’user all’interno
della stanza avviene con successo, avremo come risultato due elementi: un pun-

8
tatore a un tipo Response e una stringa vuota. In caso di errore invece ritorna
nil e la stringa “cannot handel this packet”.
Ma l’utilizzo più importante di più valori di ritorno e quello di gestire gli
errori. Si prenda come esempio la stessa funzione Handle(), ma con una piccola
modifica:

func Handle(packet packet.Packet, user *entity.User) (Response, error) {


err := joinToRoomUseCase.Handle(
usecase.NewJoinToRoomRequest(user.Username,
packet.Body["name"])
)

if err != nil {
return nil, err
}

return NewPacketResponse(), nil


}

Come si può notare tra i tipi di ritorno è presente il tipo error, che è una
interfaccia che gestisce i vari tipi di errori gestiti da Go. Ovviamente questi
errori sono personalizzabili dal programmatore qualora lo ritenesse necessario.
Dunque se si è in presenza di un errore viene stampata una stringa che ne indica
la natura, altrimenti viene posto a nil. (1)(2)

2.2.2 Metodi
Un metodo è semplicemente una funzione in cui viene indicato un ricevitore
tra la keyword func e il nome del metodo. Il ricevitore è, solitamente, di tipo
struct, ma può essere di qualsiasi tipo e serve per far si che solo quel particolare
ricevitore possa richiamare quel metodo. L’esempio successivo gestisce la dis-
connessione di un client dal server e come ricevitore vine indicato un puntatore
a una struttura di tipo client.

// client.go
type Client struct {
user *entity.User
connection *net.Conn
chain *chain.Chain
removeUser *usecase.RemoveUserUseCase
}

func (client *Client) disconnectUser() {


if client.user == nil {
return
}
println(fmt.Sprintf("User %s disconnected", client.user.Username))
client.removeUser.Handle(
usecase.NewRemoveUserRequest(client.user.Username)

9
)
client.user = nil
}

client.disconnectUser();

L’ultima istruzione è la chiamata del metodo diconnectUser() e come si può


notare viene indicato all’inizio il tipo client che la invoca.
In questo esempio è stato usato un puntatore a un tipo client e non di-
rettamente il suo valore. Questo perché, in questo caso, l’interesse era quello
di modificare lo stato di quel particolare client e non di uno generico. Inoltre
utilizzando il puntatore evitiamo di lavorare su una intera copia della struttura
che, inevitabilmente, risulta essere costosa.

2.3 Interfacce
In Go, un’interfaccia è un costrutto simile a una struttura, in grado di contenere
un insieme di metodi. Si dice che un tipo implementa un’interfaccia quando è
un ricevitore di tutti i metodi definiti all’interno di essa. Dunque l’interfaccia
specifica quali metodi dovrebbe avere un tipo e quest’ultimo decide come im-
plementarli. Si prenda in considerazione un esempio che racchiuda le principali
caratteristiche.

type Message interface {


Id() uuid.UUID
Body() string
Sender() *User
SentAt() time.Time
}

type PublicMessage struct {


id uuid.UUID
body string
sender *User
sentAt time.Time
}

func (p *PublicMessage) Id() uuid.UUID {


return p.id
}

func (p *PublicMessage) Body() string {


return p.body
}

func (p *PublicMessage) Sender() *User {


return p.sender
}

10
func (p *PublicMessage) SentAt() time.Time {
return p.sentAt
}

Subito alla prima riga abbiamo la definizione dell’interfaccia Message. È molto


simile alla creazione di una struttura, con la differenza che le interfacce con-
tengono al loro interno il contratto, ovvero i metodi che dovranno essere definiti
da chi implementa l’interfaccia, come in questo caso dalla struttura PublicMes-
sage. Essa la implementa in modo indiretto: in Go non esiste nessuna keyword
implements, bensì se una struttura implementa tutti metodi indicati da un’inter-
faccia, la prima implementerà automaticamente la seconda, rispettando quindi
il contratto. Ovviamente una struttura può implementare più di una interfac-
cia, tenendo in considerazione l’obbligo di avere i metodi definiti nei contratti
di ciascuna interfaccia.
Gli sviluppatori di Go hanno deciso di non utilizzare nessuna keyword per
implementare un’interfaccia. Questo porta a definire una frase molto comune
all’interno della comunità di Go: se cammina e schiamazza, allora è una pa-
pera. Questo significa che se una struttura implementa tutti i metodi definiti
da un’interfaccia, allora quella struttura implementa l’interfaccia stessa. (1)(2)

2.4 Goroutine
In Go ogni attività di esecuzione concorrente viene chiamata Goroutine. Una
goroutine non è altro che un metodo o una funzione in grado di essere eseguito
in parallelo con tutte le altre goroutine, ovvero di avere un flusso di esecuzione
separato. Erroneamente vengono confuse con i thread, i quai presentano delle
caratteristiche diverse e spesso limitanti rispetto alle goroutine.
All’avvio di un programma esiste una sola goroutine ed è quella che chiama la
funzione main, che viene comunemente chiamata goroutine principale. Quindi
ogni goroutine creata successivamente verrà eseguita parallelamente a quella
principale. Per quanto riguarda, invece, la creazione di una nuova goroutine è
necessaria una semplice keyword che è omonima del linguaggio stesso, ovvero
go. Questa deve essere anteposta alle funzioni o ai metodi scelti per essere
eseguiti contemporaneamente con le altre esecuzioni concorrenti.
Prendiamo in considerazione una funzione che permette a un qualsiasi client
connesso di gestire i pacchetti provenienti da una connessione TCP:

// client.go
func (client *Client) ListenForPackets() {
defer client.closeClient()
decoder := json.NewDecoder(*client.connection)
for {
var p packet.Packet
if err := decoder.Decode(&p); err == io.EOF {
break
} else if err != nil {

11
panic(err)
}
response, err := client.chain.Handle(p, client.user)
if err != nil {
panic(err)
}
switch response.(type) {
case *handler.LoginResponse:
user := response.(*handler.LoginResponse).GetUser()
if user != nil {
client.connectUser(user)
}
}
for _, p = range response.Packets() {
client.SendPacket(p)
}
}
}

Nel file server.go è, invece, presente il codice che, dopo l’avvenimento di una
connessione, crea un oggetto client. Questo, successivamente, farà la chiamata
al metodo ListenForPackets().

//server.go
for {
connection, _ := listener.Accept() // Gestisce la connessione dei
client
client := client.NewClient(&connection, chain, removeUser) // creo
un oggetto client
server.clients = append(server.clients, client)
go client.ListenForPackets() // creo una goroutine
}

Come possiamo vedere vicino al metodo client.ListenForPacket() è presente la


keyword go, la quale ci permette di trasformare questo metodo in una goroutine
in grado di eseguire le istruzioni definite nel metodo con un flusso di esecuzione
diverso rispetto alla goroutine principale. (1)(2)

2.5 Channel
Se le goroutine sono le attività che permettono la programmazione concorrente,
i channel sono una sorta di connessione che permette la comunicazione tra
le prime. Un channel è un meccanismo di comunicazione che consente a una
goroutine di inviare valori a un’altra goroutine. Ogni channel è un “condotto”
in grado di contenere i valori di un solo tipo, scelto durante la dichiarazione del
channel.
Inoltre il channel è bidirezionale. Questo permette alle goroutine di inviare
e ricevere dati attraverso lo stesso canale. Vediamo adesso come crearne una

12
contenente un valore di tipo bool:

loggedUsersChannel := make(chan bool)

Come si può vedere è necessaria la funzione make che prende come parametro
chan bool : la prima serve per indicare che si tratta di un channel, mentre la
seconda rappresenta il tipo del valore.
Un channel è una reference a una struttura dati creata da make. Quindi
quanto copiamo un channel o ne passiamo una a una funzione stiamo passando
una reference.
Come detto prima, un channel ha due principali operazioni, scrittura e let-
tura, che prendono il nome di comunicazione. Per poter far ciò abbiamo bisogno
dell’operatore <-, usato sia in fase di lettura che quella di scrittura:

loggedUsersChannel <- true // scrittura/assegnamento del valore true a


loggedUsersChannel
loggedUser <-loggedUsersChannel // lettura del valore di
loggedUsersChannel e assegnata a loggedUser

loggedUserChannel viene definito come channel unbuffered in quanto


all’interno della stessa è presente un solo elemento. Non per caso il costrutto
make accetta un altro argomento di tipo int che indica la capacità del channel,
ovvero il numero di valori dello stesso tipo che sono all’interno dello stesso canale
di comunicazione.

loggedUsersChannel := make(chan bool) // channel non bufferizzato


loggedUsersChannel := make(chan bool, 0) // channel non bufferizzato
loggedUsersChannel := make(chan bool, 3) // channel bufferizzato con
capacita’ massima di 3 valori

Utilizzando un channel bufferizzato, come nell’ultimo caso, si ha a disposizione


la possibilità di scrivere e leggere fino a un massimo di 3 valori. L’accesso a
questi valori avviene similmente come per una coda: il primo valore scritto sarà
il primo ad essere letto. (1)(2)

2.6 Controllo del flusso


2.6.1 Defer
L’istruzione Defer viene utilizzata per posticipare l’esecuzione di un’istruzione
appena prima che terminino tutte le istruzioni nello scope dell’istruzione preso
in considerazione:

func (r *InMemoryUserRepository) Save(user *entity.User) {


r.sync.Lock()
defer r.sync.Unlock()
r.users[user.Username] = user
}

13
Questo codice permette il salvataggio, in modo sincronizzato, di un’entità user
all’interno di un repository. Come si può intuire, la prima e la seconda istruzione
permettono l’acquisizione e il rilascio del lock per accedere alla sezione critica,
che in questo caso è rappresentata dall’ultima istruzione. Nonostante il metodo
Unlock() sia scritto prima, questa verrà eseguita come ultima istruzione della
funzione.
Qualora ci fossero più istruzioni defer, queste verrebbero inserite in uno stack
ed eseguite nell’ordine LIFO (Last In First Out). Quindi l’ultima istruzione defer
è la prima ad essere eseguita e via proseguendo fino al primo defer. (1)(2)

2.6.2 Panic
Nel capito 2.2.1 è stato introdotto il concetto di error e di come questo sia utile
per indicare una sorta di anomalia nel progetto che è in grado di essere gestito
e che, al contrario delle eccezioni, non terminano l’esecuzione del programma.
Difatti gli errori sono sufficienti per la maggior parte delle condizioni anomale
che si verificano nel programma.
Ma ci sono alcune situazioni in cui il programma non può continuare l’esecu-
zione dopo una condizione anomala. In questo caso, usiamo Panic per terminare
prematuramente il programma. Quando una funzione incontra un panic, la
sua esecuzione viene interrotta e tutte le istruzioni defer, qualora ce ne siano,
vengono eseguite. A quel punto il programma stampa il messaggio di panico.

if err != nil {
panic(err)
}

Ci sono due modi per andare in panic: il primo modo avviene quando l’errore
che vogliamo gestire è considerato irreversibile, mentre il secondo avviene per
errori del programmatore come la classica divisione per 0. (1)(2)

2.6.3 Recover
Il Recover è una funzione incorporata che viene utilizzata per riprendere il con-
trollo di un programma che è andato in panic, ovvero per un errore non gestibile
che causa il blocco del flusso di esecuzione.

// client.go
defer client.closeClient()
defer func() {
if r := recover(); r != nil {
client.closeClient()
println("Client disconnect due to panic")
}
}()
decoder := json.NewDecoder(*client.connection)
for {
var p packet.Packet

14
if err := decoder.Decode(&p); err == io.EOF {
break
} else if err != nil {
panic(err)
}
response, err := client.chain.Handle(p, client.user)
if err != nil {
panic(err)
}
switch response.(type) {
case *handler.LoginResponse:
user := response.(*handler.LoginResponse).GetUser()
if user != nil {
client.connectUser(user)
}
}
for _, p = range response.Packets() {
client.SendPacket(p)
}
}

Questo codice permette al client di rimanere in attesa di ricevere un pacchetto


da altri client o dal server. Qualora, però, ci sia un errore, che in questo è
rappresentato dalla errata decodifica del pacchetto o dall’impossibilità di gestire
il pacchetto, risulta estremo bloccare l’esecuzione del client con la sua successiva
disconnessione. Per evitare ciò bisogna usare il costrutto defer-recover, ovvero
creare una funzione anonima in cui all’interno viene gestita la recover e quindi il
proseguimento del flusso di esecuzione dal punto in cui si era interrotto. (1)(2)

15
3 Caratteristiche principali di Go
3.1 Compilazione
3.1.1 Compilazione
Go è un linguaggio compilato. La caratteristica dei linguaggi compilati come C,
C++ e lo stesso Go è che vengono convertiti direttamente in codice macchina
che il processore è in grado di eseguire.
I linguaggi compilatati richiedono una fase di “costruzione” o di build : devono
quindi essere compilati “manualmente”. Nel caso di Go per generale un file
eseguibile basterà scrivere da terminale

go build main.go

In realtà Go permette di saltare la fase di build utilizzando il comando

go run main.go

In questi caso il codice viene automaticamente compilato e successivamente


eseguito, con la differenza che non viene generato nessun file eseguibile. Questo
comando risulta utile quando si vuol eseguire test rapidi e codici soggetti a
modifica, senza il dover tutte le volte compilare il programma.(3)
La maggior parte dei competitor di Go sono i linguaggi interpretati come
Python e PHP. La caratteristica di questi linguaggi è che il codice sorgente
viene tradotto in un linguaggio binario e successivamente compilato, causando
inevitabilmente un calo di prestazioni.
Per quanto riguarda Go, tutto ciò di cui si ha bisogno per eseguire un pro-
gramma è un singolo file binario di pochi Megabyte. Per eseguire i programmi,
i linguaggi interpretati hanno la necessità di accedere a tutti i file del codice
sorgente e un intero ambiante di runtime installato. Con Go questo non è nec-
essario.
La differenza sostanziale tra Go e questi linguaggi è rappresentata dalle
rapide prestazioni che la prima fornisce a differenza delle altre. Infatti eseguendo
un semplice codice che calcola il fattoriale di alcuni numeri si possono notare
delle differenze non indifferenti(4):

//GO
func main() {
Inputs := [4]int64{10000, 50000, 100000, 500000}
for i := 0; i < len(Inputs); i++ {
var before = time.Now().UnixNano() / int64(time.Millisecond)
var factorial = big.NewInt(1)
var j int64
for j = 1; j <= Inputs[i]; j++ {
factorial = factorial.Mul(factorial, big.NewInt(j))
}
var after = time.Now().UnixNano() / int64(time.Millisecond)

16
var time = after - before
fmt.Println(Inputs[i], time)
}
}

10000 0,01 secondi


50000 0,389 secondi
100000 1,509 secondi
500000 49,88 secondi

//JAVA
public static void main(String[] args){
int[] allInputs = new int[]{10000, 50000, 100000, 500000};
for(int i=0;i<allInputs.length;i++){
BigInteger number = BigInteger.valueOf(allInputs[i]);
BigInteger result = BigInteger.valueOf(1);
long before = System.currentTimeMillis();
for (int j=1;j<=allInputs[i];j++){
result = result.multiply(BigInteger.valueOf(j));
}
long after = System.currentTimeMillis();
System.out.println("Elapsed Time: "+(after - before));
}
}

10000 0,07 secondi


50000 1,172 secondi
100000 4,493 secondi
500000 135,067 secondi

Dai tempi stampati si può facilmente constatare che Go è nettamente più


prestante, arrivando addirittura ad eseguire il calcolo in meno della metà del
tempo impiegato da Java. Come sappiamo, a seconda di come vengono im-
plementati i concetti, si possono ottenere delle pessime prestazioni rispetto a
concetti ben implementati. Se a questi affianchiamo le prestazioni di linguaggio
è certo l’ottenimento di ottimi risultati.
Un’altra caratteristica di Go è che ci permette di ottenere un’efficace con-
trollo degli errori durante la fase di compilazione, ovvero prima che il nostro
file venga eseguito. Ciò può prevenire errori imprevisti nella produzione e far
risparmiare agli sviluppatori molto tempo per la ricerca dei bug.(5)(6)

3.1.2 Compilazione incrociata


Go è un linguaggio multi-platform. Infatti una delle funzionalità più potenti di
Go è la possibilità di creare file eseguibili incrociati per qualsiasi piattaforma
diversa da quella attualmente in uso. Ciò rende molto più semplici i test e

17
la distribuzione dei pacchetti, poiché non è necessario avere accesso a una pi-
attaforma specifica per distribuire il pacchetto. I linguaggi interpretati non
hanno bisogno della cross-compilazione in quanto basta che sia installata nella
macchina l’interprete che permetta l’esecuzione del codice. Ma come sappiamo,
oltre a sprecare memoria per l’interprete, i linguaggi interpretati sono più lenti
di quelli compilati. D’altro canto, per quanto riguarda i linguaggi compilati
come C++, non è per niente facile eseguire una cross-compilazione neanche uti-
lizzando CMake. Qualora si stia lavorando in un sistema linux e si volesse poi
passare a un sistema Windows, con C++ il modo più “semplice” sarebbe quella
di installare e configurare WSL (Windows Subsystem for Linux) su Windows
ed eseguire la compilazione. Con Go, invece, la possibilità di cambiare da un
OS ad un altro avviene in modo immediato tramite due variabili d’ambiente:
GOOS e GOARCH. il primo serve per indicare il sistema operativo, mentre il
secondo serve per indicare l’architettura. Inoltre nella fase di build, possiamo
indicare più sistemi operativi e architetture:

GOOS=windows GOARCH=amd64 go build -o main.exe main.go


GOOS=darwin GOARCH=386 go build -o main main.go
GOOS=linux GOARCH=arm go build -o main main.go

In questo modo abbiamo creato un unico eseguibile in grado essere eseguito in


3 ambienti diversi senza dover installare nessun tool o interprete.
I test eseguiti sono stati svolti nella stessa macchina che presenta il sis-
tema operativo Linux e un’architettura AMD. In seguito è stata installata una
macchina virtuale con Windows, naturalmente con la stessa architettura. È
stato quindi cross-compilato il modulo Chat-Client su Linux e fatto eseguire su
Windows, creando cosi un client che comunica attraverso la porta: 8000 con
connessione TCP. (7)

3.2 Concorrenza
3.2.1 Concorrenza e Parallelismo
Prima di parlare di come funzionano gli aspetti più caratteristici di Go, ovvero
la gestione della concorrenza e del parallelismo, diamo alcune definizioni:
Concorrenza: è una tecnica utilizzata per diminuire il tempo di risposta del
sistema utilizzando un’unica unità di elaborazione o elaborazione sequenziale.
Un’attività è divisa in più parti e la sua parte viene elaborata contemporanea-
mente ma non nello stesso istante. Produce l’illusione del parallelismo attraverso
la commutazione di contesto, ma in realtà i pezzi di un’attività non vengono
elaborati parallelamente.
Parallelismo: è concepito allo scopo di aumentare il velocità di calcolo uti-
lizzando più processori. È una tecnica per eseguire simultaneamente i diversi
compiti nello stesso istante. Coinvolge diverse unità di elaborazione o dispositivi
di elaborazione indipendenti che operano ed eseguono parallelamente attività al
fine di aumentare la velocità di calcolo e migliorare la produttività.(8)
A differenza di altri linguaggi di programmazione come Java, C++ e Python,

18
Go è stato creato in un contesto storico in cui la gestione della concorrenza era
una problematica all’ordine del giorno e soprattutto erano già diffusi i processori
multi-core. Questo ha reso possibile quindi la creazione di un linguaggio di
programmazione in grado di poter affrontare la programmazione concorrenziale
nel modo più efficace, semplice e soprattutto internamente senza quindi l’ausilio
di implementazioni successive e librerie esterne.
Inoltre abbiamo affrontato il concetto di parallelismo in quanto Go, a dif-
ferenza di altri linguaggi, rende possibile il controllo del numero di thread del
sistema operativo che in quel momento stanno gestendo le varie goroutine. Pos-
siamo fare ciò grazie all’ausilio della variabile d’ambiente GOMAXPROCS.(1)
Per capire meglio la potenzialità si pensi a un classico calcolatore in cui vengono
eseguite più applicazioni che concorrono tra di loro per l’ottenimento di dei core
di uno o più CPU. Se la nostra applicazione Go non necessità di parallelismo
è giusto diminuire il valore di GOMAXPROCS permettendo quindi alle altre
applicazioni di concorrere con un applicazione in meno.
Impostando al massimo il numero di core da utilizzare si può pensare di poter
accelerare l’esecuzione generale del programma, ma non sempre è così: se un
programma dedica più spazio alla comunicazione e alla sincronizzazione rispetto
alla parallelizzazione, questo comporterà un degrado in termini di prestazione
in quanto si utilizzeranno più thread del sistema operativo e il loro passaggio di
dati comporta un dispendioso cambio di contesto. (9)
Dunque bisogna valutare il tipo di operazione che effettuano le varie gor-
outine che compongono il programma e decidere di conseguenza. Nel caso del
progetto della Chat, in entrambi i moduli Chat-Server e Chat-Client ci sono
molte attività di comunicazione. Quindi aumentando il numero di core da uti-
lizzare comporterebbe a un degrado prestazionale.

3.2.2 Goroutine vs Thread


Come abbiamo spiegato prima le goroutine sono delle funzioni o metodi che
vengono eseguiti in modo concorrenziale con altre funzioni o metodi. Il thread è
un processo leggero che può essere trattato come un’unità per eseguire un pezzo
di codice. Esistono molte differenze sostanziali tra i thread e le goroutine.
• Le goroutine sono estremamente leggere paragonate ai thread. Una gor-
outine ha infatti uno stack 2 kilobyte di dimensione minima che può essere
incrementato o decrementato a differenza di un thread che ha una dimen-
sione fissa di 320 kilobyte. Questa diversità genera una differenza enorme
in termini di numero di gorutine che possono essere utilizzate in un dato
momento rispetto ai thread. Prendiamo in considerazione il Chat-Server,
il quale deve gestire diverse goroutine che rappresentano i vari client che
tentano di ricevere i vari pacchetti:

for {
connection, _ := listener.Accept()
client := client.NewClient(&connection, chain, removeUser)
server.clients = append(server.clients, client)

19
go client.ListenForPackets()
}

In questo modo, in un sistema con 1 GB di RAM a disposizione, il server


può gestire fino a 500’000 client alla volta. Qualora avessimo usato i thread
avremmo potuto gestire soltanto poco più 3000 client.
• Quando mandiamo in esecuzione un pogramma scritto in Go, il runtime di
Go crea alcuni thread su un core. Le goroutine, quindi, sono multiplexate
(generate) su questi thread. In questo modo e in qualsiasi momento, il
thread in questione eseguirà una goroutine; e se quella è bloccata, verrà
sostituita con un’altra che verrà eseguita sempre su quel thread. È simile
al concetto di scheduling dei thread, ma viene gestito dal runtime di Go
che lo rende molto più veloce. Invece i normali thread vengono gestiti dal
kernel e li rende quindi dipendenti dall’hardware in quanto sono mappati
direttamente con i thread del sistema operativo a differenza delle gorou-
tine.
• I thread non hanno un modo semplice di comunicare se non con una memo-
ria condivisa che però rende complicata la comunicazione e crea una grossa
latenza. Le goroutine invece hanno un semplicissimo mezzo di comuni-
cazione, ovvero i channel che hanno la caratteristica di essere bloccanti,
ovvero solo una goroutine alla volta può effettuare un’operazione su di
essa evitando quindi molti problemi come ad esempio le race condition e
i deadlock. Infatti il mantra di Go è non è comunicare condividendo la
memoria, ma condividere la memoria comunicando.
• I thread hanno un identificatore chiamato TID. Le goroutine non hanno
nessun identificatore in quanto Go non implementa nessun TLS(Thread
Local Storage). Gli sviluppatori Go affermano che assegnando un identi-
ficatore a una goroutine questa diventa “speciale” portando quindi il pro-
grammatore ad associare molte operazioni ad essa, ignorando la possibilità
di usare più goroutine per l’elaborazione(9).

• I thread hanno costi significativi poiché durante la creazione deve richiedere


molte risorse e che, successivamente, deve restituire una volta terminata
l’esecuzione, mentre per le goroutine è diverso: vengono create e distrutte
dal runtime di Go rendendo queste operazioni molto economiche rispetto
ai thread poiché il runtime di Go mantiene già un pool di thread per le
goroutine.

(10)

3.2.3 Sincronizzazione e Comunicazione


Gli sviluppatori di Go hanno reso molto semplice e intuivo il modello concorren-
ziale creando un vero e proprio pattern in grado di incitare il programmatore a

20
creare molte goroutine e a utilizzare i channel sia per la comunicazione che per
la sincronizzazione.
Come detto in precedenza il modello di concorrenza di Go si basa sul con-
cettonon comunicare condividendo la memoria, ma condividere la memoria co-
municando e che viene rappresentato dall’immagine sottostante:

(11)

I channel vengono usati quindi sia come metodo di comunicazione che metodo
di sincronizzazione fra le varie goroutine soprattutto grazie alla proprietà bloc-
cante che hanno sulle ultime. Ad esempio in queste righe di codice vediamo
come il channel chiamato messagesSentChannel permetta di scrivere il valore
all’interno di un altro channel chiamato messagesSent ogni qualvolta che viene
inviato un messaggio. Questo ci permette di creare una sorta di sincronizzazione
tra le goroutine in modo da gestire l’incremento della variabile in modo tale da
non creare un’inconsistenza dei dati che rappresentano, fino a quel momento,
tutti i messaggi inviati dai client:

//send_message.go
func (handler *SendMessageHandler) Handle(packet packet.Packet, user
*entity.User) (Response, error) {
if user == nil {
return nil, fmt.Errorf("handler need authentication")
}
if user.Username != packet.Body["username"] {
return nil, fmt.Errorf("logged user mismatch with username in
packet request")
}
publishMessageRequest := usecase.NewPublishMessageRequest(
packet.Body["text"],
user.Username,
)
err := handler.useCase.Handle(publishMessageRequest)

if err != nil {
return nil, err

21
}
handler.messagesSentChannel <- true \\ il channel viene messo a true
return NewPacketResponse(), nil
}

//server.go
func (server *Server) consumeMessagesSentChannel() {
for {
messageSent := <-server.messagesSentChannel
if messageSent {
server.messagesSent += 1 // incremento del valore del channel
fmt.Printf("Total messages sent: %d\n", server.messagesSent)
}
}
}

In questo contesto, il programmatore non dovrà preoccuparsi di creare e ge-


stire in modo diretto una sezione critica alla quale dovranno accedervi le varie
goroutine. Questa proprietà del linguaggio permette di ridurre gli errori di pro-
grammazione commessi appunto dai programmatori che adottano la program-
mazione concorrenziale. Ovviamente non è sempre immediato l’uso dei channel
nella sincronizzazione delle goroutine e tal volta risulta innaturale.
Il codice sottostante serve per gestire la persistenza delle entità messages,
ovvero quando viene inviato un nuovo messaggio è necessario che questo venga
salvato in un qualsiasi repository, nel nostro caso in memoria. Per far ciò,
qualora avessimo usato i channel, avremmo dovuto creare un numero di channel
pari al numero di client connessi fino a quel momento. Questo non è fattibile a
priori poiché non è noto il numero di client connessi in qualsiasi momento. Una
soluzione sarebbe quella di creare un channel nel momento della creazione di
un un client solo per gestire il salvataggio dei messaggi. Questo risulta essere
innaturale e difficilmente gestibile in quanto, adottando lo stesso principio anche
con altri channel, se ne creerebbero troppe. Il package sync(1)(12) fornisce le
primitive di sincronizzazione di base come lock per la mutua esclusione:

type InMemoryMessageRepository struct {


messages map[string]entity.Message
sync sync.RWMutex // lock di mutua esclusione per lettura\scrittura
}

func NewInMemoryMessageRepository() *InMemoryMessageRepository {


return &InMemoryMessageRepository{messages:
make(map[string]entity.Message)}
}

func (i *InMemoryMessageRepository) Save(message entity.Message) {


i.sync.Lock() // acquisizione del lock
defer i.sync.Unlock() // rilascio del lock

22
i.messages[message.Id().String()] = message // inserimento del nuovo
messaggio
}

Anche in questo caso la mutua esclusione risulta molto semplice e intuitiva da


gestire.
Possiamo inoltre fare un paragone con Java che fornisce un idioma di sin-
cronizzazione dei metodi. Per far ciò basta mettere al metodo la keyword syn-
cronized (13). Gli errori sono molto frequenti in quanto si potrebbe cercare
di fare una modifica non sincronizzata qualora il programmatore non lo abbia
gestito bene. Java fornisce anche delle interfacce che permettono di risolvere i
problemi più ridondanti, ma dato che ci sono diversi tipi di problemi (lettore-
scrittore, produttore-consumatore, ...) questo comporta il doversi informare di
tutte le varie interfacce che li gestiscono.
Per concludere l’argomento vediamo adesso un esempio di comunicazione
tramite channel: il server crea una goroutine che rimane in attesa dei pacchetti
che arrivano dai vari client in modo da gestire l’inoltro verso tutti gli altri client.
Questo lo fa grazie alla lettura del channel broadcastChannel che contiene il
pacchetto inviato tramite connessione TCP.

// server.go
func (server *Server) Start() {
//operazioni varie

go server.consumeBroadcastChannel()

//operazioni varie
}

func (server *Server) consumeBroadcastChannel() {


for {
packet := <-server.broadcastChannel // lettura del channel
if packet.Header == "messageInRoomPublished" {
go server.broadcastInRoom(packet, packet.Body["room"])
continue
}
go server.broadcastToAll(packet)
}
}

3.2.4 Prestazioni
Per concludere il capitolo concorrenza, si consideri il successivo paragone fatto
tra Go e Java. È stato scritto un codice in entrambi i linguaggi in grado di
effettuare una connessione TCP in grado di comunicare con il programma Chat-
Server tramite la porta :8000. Per ogni test sono stati, quindi, generati un
numero preciso di goroutine e thread. Per misurare i tempi di tutte le attività

23
concorrenti, sia in Go che in Java, è stato tenuto in considerazione il tempo di
inizio e di conclusione per poi ottenere una somma in grado di definire il tempo
totale.

//JAVA
public class Benchmark extends Thread{
static int numberOfThread = 256;
static long totalTime;

public static synchronized void increment(long elapsed) {


totalTime = totalTime + elapsed;
}
public static void connTcp() {
try {
long start = System.nanoTime();
int serverPort = 8000;
InetAddress host = InetAddress.getByName("localhost");

Socket socket = new Socket(host,serverPort);

socket.close();
long elapsed = System.nanoTime()-start;
increment(elapsed);
System.out.println(elapsed);
}
catch(UnknownHostException ex) {
ex.printStackTrace();
}
catch(IOException e){
e.printStackTrace();
}
}

public void run() {


connTcp();
}

public static void main(String[] args) throws InterruptedException {


Benchmark threads[] = new Benchmark[numberOfThread];
for (int i = 0; i < numberOfThread; i++){
threads[i] = new Benchmark();
}

for (int i = 0; i < numberOfThread; i++){


threads[i].start();
}

sleep(5000);
System.out.println("total time: "+totalTime);
}

24
}

1 0,020260523
10 0,248550925
100 2,185914845
256 3,530972005

//GO
var numberOfGoroutine = 256
var wg sync.WaitGroup
var m sync.Mutex
var totalTime float64

func connTcp() {
wg.Add(1)
defer wg.Done()
var t1 = time.Now()
_, err := net.Dial("tcp", ":8000")
if err != nil {
fmt.Println(err)
return
}
m.Lock()
t := time.Since(t1).Seconds()
totalTime = totalTime + t
m.Unlock()
fmt.Println(t)
}

func main() {
for i := 0; i < numberOfGoroutine; i++ {
go connTcp()
}
wg.Wait()
time.Sleep(3 * time.Second)
fmt.Println("total time")
fmt.Printf("%v\n", totalTime)
}

1 0.000181727
10 0.002438719
100 0.047565707999999984
256 0.3963921749999999

Sono stati stati eseguiti 4 test con differenti numeri di attività concorrenti: 1,
10, 100 e 256. L’ultimo numero è stato scelto in quanto la JVM riesce a gestire

25
fino a un massimo di 256 thread. In ogni caso questi dati sono abbastanza utili
per mostrare una sostanziale differenza tra i due linguaggi. Difatti, per ogni
numero di attività concorrente, Go si dimostra essere di molto superiore a Java.
Ovviamente queste analisi di prestazione non mostrano un’assoluta verità, bensì
devono essere presi come un’indicazione di questo specifico caso. Questo per-
ché ci possono essere diverse soluzioni a un singolo problema, così come per
la concorrenza di Java. Questo, però, afferma ancora di più uno degli aspetti
fondamentali di Go, ovvero quello di essere un linguaggio prestante permet-
tendo al programmatore di evitare la ricerca di particolari abilità e, soprattutto,
conoscenze di programmazione.

3.3 OOP
Se venisse posto a diversi programmatori la domanda “Go è un linguaggio ori-
entato agli oggetti?”, molto probabilmente non ci sarebbe una risposta unanime
come nel caso venisse posta ad esempio per Java o per C. E molto probabilmente
le risposte potrebbero dipendere dal linguaggio in cui programmano, ovvero: chi
programma in C potrebbe rispondere che Go è orientato agli oggetti, mentre
il contrario per i programmatori Java(14). Questo perché Go non supporta le
classi e gli oggetti, bensì utilizza le strutture e le interfacce. Inoltre non rispetta
tutti i 4 principi della programmazione ad oggetti o meglio ancora li ha adattati
in base alle proprie necessità.

3.3.1 Incapsulamento
Per incapsulamento si intende la possibilità di incapsulare o rendere privato un
qualsiasi dato all’interno di un oggetto(15). Ma dato che che in Go gli oggetti
non esistono, questo concetto viene riadattato a livello package o subpackage
che comprende anche i singoli file. Quando viene importato un package o un
file locale, tutti i dati pubblici vengono acceduti in modo diretto, viceversa per
quelli privati. Non esiste, però, nessuna keyword public o private che rappresenti
tutto ciò, bensì per rendere pubblico un metodo o una variabile, in fase di
dichiarazione, è necessario porre la prima lettera del nome in maiuscolo. Tutto
ciò che non ha la prima lettera maiuscola è invece resa privata e quindi non
modificabile dall’esterno:

func (client *Client) SendPacket(p packet.Packet) { //metodo pubblico


encodedPacket, _ := json.Marshal(p)
(*client.connection).Write(encodedPacket)
}

func (client *Client) connectUser(user *entity.User) { // metodo privato


println(fmt.Sprintf("User %s connected", user.Username))
client.user = user
}

26
3.3.2 Ereditarietà e Composizione
L’ereditarietà è un principio che si verifica quando diverse classi presentano delle
logiche comuni che porta quindi alla definizione di una superclasse che racchiude
queste logiche che verranno ereditate delle sottoclassi(15).
Da molto l’ereditarietà viene visto come un approccio da evitare in quanto
provoca diversi problemi come il problema del diamante che si verifica quando le
ereditarietà create, viste con un’immagine, formano un rombo(16). Il successivo
schema ne è un esempio:
• B e C ereditano da A;
• D eredita sia da B che da C;
Dunque se c’è un metodo in A che B e C hanno sovrascritto e D non lo
sovrascrive, quale versione del metodo D eredita: quella di B o quella di C?
Già nel 1994 veniva discusso nel libro Gof quanto l’ereditarietà fosse un
principio da non utilizzare, preferendo invece la Composizione e ricercando in
essa il comportamento polimorfico.
In Go il problema muore in partenza in quanto non supporta l’ereditarietà
incitando il programmatore ad usare la composizione. Questo principio si basa
sul fatto che una classe contiene un’altra classe. Nel contesto Go ovviamente
non si parlerà di classi, bensì di strutture, le quali ci permettono di avere un
concetto analogo. Quindi se la struct B contiene una struct A, la prima sarà in
grado di utilizzare tutti metodi pubblici della seconda. Rileggendo le definizioni
di ereditarietà e composizione possiamo già notare una differenza di relazione
che intercorre tra gli esempi di A e B:
• B eredita A -> B è un A;
• B è composto da A -> B ha un A;
Nell’esempio successivo viene indicata una struttura PublicMessage che serve
per rappresentare un messaggio che viene ricevuti da tutti i client connessi;
un’altra struttura RoomMessage che serve per rappresentare un messaggio in-
viato in una stanza specifica e quindi a tutti i client connessi presenti in quella
stanza. RoomMessage contiene/ha un PublicMessage.

type PublicMessage struct {


id uuid.UUID
body string
sender *User
sentAt time.Time
}

type RoomMessage struct {


*PublicMessage
room *Room
}

27
3.3.3 Polimorfismo
Il polimorfismo rappresenta il concetto di assumere diverse forme e ne esistono
due tipi, elencati tenendo in considerazione che sono descritti in un contesto
orientato agli oggetti
• Statico: si verifica quando un oggetto consta di diversi metodi aventi lo
stesso nome, ma con diversi parametri che differiscono tra loro per numero,
ordine o tipo.

• Dinamico: Quando una sottoclasse estende una superclasse eredita tutti


tutti i suoi metodi, i quali possono essere possono essere oggetto di override
per specificarne la propria implementazione. Questo tipo di polimorfismo
avviene in fase di runtime e consiste nella capacità di agganciare dinami-
camente durante l’esecuzione del codice il metodo corretto, utilizzando
l’implementazione presente nel tipo dell’oggetto e non nel tipo del riferi-
mento utilizzato durante la scrittura del codice (design time).
Ritorniamo però nel contesto Go per vedere quali sono le differenze. Innanzi-
tutto il polimorfismo statico in Go è completamente assente. Difatti non possono
esistere due funzioni con lo stesso nome.

func Save(message entity.Message) {


switch message.(type) {
case *entity.RoomMessage:
m.messages[message.(*entity.RoomMessage).Room().Name] =
[]entity.Message{message}
break
}
}

func Save(user *entity.User) {


r.sync.Lock()
defer r.sync.Unlock()
r.users[user.Username] = user
}

Queste due funzioni servono per salvare in una struttura dati rispettivamente le
due entità Messagge e User. Sebbene abbiano i parametri che differiscono per
tipo, in questo caso si verificherebbe un errore in fase di compilazione in quanto
condividono lo stesso nome. Il modo per ovviare a questo problema è quello di
assegnare un ricevitore diverso per ogni metodo. Ovviamente se assegnassimo
lo stesso ricevitore genererebbe comunque errore.

func (r *InMemoryMessageRepository) Save(message entity.Message) {


switch message.(type) {
case *entity.RoomMessage:
m.messages[message.(*entity.RoomMessage).Room().Name] =
[]entity.Message{message}

28
break
}
}

func (r *InMemoryUserRepository) Save(user *entity.User) {


r.sync.Lock()
defer r.sync.Unlock()
r.users[user.Username] = user
}

Per gli sviluppatori di Go questo permette di ottenere una semplificazione dal


punto di vista della programmazione in quanto avere dei metodi con lo stesso
nome può comportare a confusioni e fragilità nel progetto.(9)
Per quanto riguarda il polimorfismo dinamico, questo si ottiene in fase di
esecuzione del programma o a runtime. In Go avviene tramite l’utilizzo delle
interfacce e al modello “se cammina e schiamazza allora è un’anatra”.

type Message interface {


Id() uuid.UUID
Body() string
Sender() *User
SentAt() time.Time
}

type PublicMessage struct {


id uuid.UUID
body string
sender *User
sentAt time.Time
}

func NewPublicMessage(id uuid.UUID, body string, sender *User, sentAt


time.Time) *PublicMessage {
return &PublicMessage{id: id, body: body, sender: sender, sentAt:
sentAt}
}

func (p *PublicMessage) Id() uuid.UUID {


return p.id
}

func (p *PublicMessage) Body() string {


return p.body
}

func (p *PublicMessage) Sender() *User {


return p.sender
}

29
func (p *PublicMessage) SentAt() time.Time {
return p.sentAt
}

func (p *PublicMessage) Type() string {


return "Public message"
}

type RoomMessage struct {


*PublicMessage
room *Room
}

func NewRoomMessage(
id uuid.UUID,
body string,
sender *User,
sentAt time.Time,
room *Room,
) *RoomMessage {
publicMessage := NewPublicMessage(id, body, sender, sentAt)
return &RoomMessage{publicMessage, room}
}

func (p *RoomMessage) Type() string {


return "Private message"
}

func (p *RoomMessage) Room() *Room {


return p.room
}

All’inizio del codice è presente l’interfaccia Message che definisce il com-


portamento di un messaggio. In seguito le strutture dati RoomMessage e Pub-
licMessage implementano l’interfaccia. La prima perché rispetta il contratto
imposto dall’interfaccia ovvero implementa i metodi definiti in essa. La seconda
invece avviene in modo indiretto in quanto è composta da un PublicMessage.
Per ottenere il comportamento polimorfico l’esempio più semplice è quello di
creare un array di Message e inserire al suo interno tutti vari tipi di messaggi:

publicMessage := message.NewPublicMessage()
roomMessage := message.NewRoomMessage()

var testMessages []message.Message

testMessages = append(testMessages, publicMessage)


testMessages = append(testMessages, roomMessage)

for _, message := range testMessages {

30
body := message.Tipe()
fmt.Printf(body)
}

Quindi dopo aver creato un messaggio pubblico e uno privato, questi vengono
inseriti in un’array di tipo Message. Si esegue quindi un ciclo su tutti gli elementi
di tipo Message e per ognuno di essi si esegue il metodo message.Type() e si
stampa il risultato. In questo modo possiamo verificare che l’elemento message
sfrutti il polimorfismo per invocare il Type() in base al tipo del dell’elemento
corrente durante l’iterazione. La stama sarà:

sono un messaggio pubblico


sono un messaggio su stanza

3.3.4 Astrazione
I concetti di astrazione permettono la creazione di una soluzione a un problema
ridondante. Gli stessi concetti sono rappresentabili pure in Go in quanto non
bisogna seguire in specifico pattern come ad esempio per l’incapsulamento.

3.4 Garbage Collection


Uno degli aspetti controllati in fase di sviluppo è la gestione della durata degli
oggetti allocati. In linguaggi come il C in cui viene eseguito manualmente, può
consumare una notevole quantità di tempo per il programmatore ed è spesso
la causa di bug dannosi. Anche in linguaggi come C++ o Rust che forniscono
meccanismi di supporto, questi meccanismi possono avere un effetto significativo
sulla progettazione del software, spesso aggiungendo un proprio sovraccarico di
programmazione. Gli sviluppatori di Go hanno ritenuto fondamentale eliminare
tali spese generali del programmatore, con una latenza sufficientemente bassa e
che potesse essere un approccio praticabile per i sistemi in rete.
L’attuale implementazione di Go è un raccoglitore mark-and-sweep: Se la
macchina è un multiprocessore, il collettore viene eseguito su un core della CPU
separato, ovvero in parallelo con il programma principale. Il lavoro importante
sul collettore negli ultimi anni ha ridotto i tempi di pausa spesso al di sotto del
millisecondo, anche per grandi heap, eliminando del tutto una delle principali
obiezioni alla raccolta dei rifiuti nei server in rete.
Uno degli aspetti più caratteristici del GC di Go è che fornisce al programma-
tore gli strumenti per limitare l’allocazione controllando il layout delle strutture
dati. Considera questa semplice definizione del tipo di un array contenente
elementi di tipo byte:

type X struct {
a, b, c int
buf [256]byte
}

31
In Java, il campo buf richiederebbe una seconda allocazione e per accedervi un
secondo livello di indirizzamento. In Go, invece, il buffer è allocato in un singolo
blocco di memoria insieme alla struttura che lo contiene e non è richiesta alcun
indirizzamento. Questo design può avere prestazioni migliori oltre a ridurre il
numero di elementi noti al collezionista. Su larga scala può fare una differenza
significativa.(17)

3.5 Dipendenze
Il primo passo per rendere Go scalabile dal punto di vista delle dipendenze, è
stato il fatto di generare un errore in fase di compilazione per le dipendenze
definite, ma mai utilizzate. Se il file di origine importa un pacchetto che non
utilizza, il programma non verrà compilato. Questo garantisce che l’albero delle
dipendenze sia in linea con le tecnologie utilizzate e che non abbia dipendenze
inutile. Ciò, a sua volta, garantisce che nessun codice aggiuntivo venga compi-
lato durante la compilazione del programma, riducendo al minimo il tempo di
compilazione.
C’è un altro passaggio, questa volta nell’implementazione dei compilatori,
che va ancora oltre per garantire l’efficienza. Si consideri un programma Go con
tre pacchetti e questo grafico delle dipendenze:

• package A importa package B;


• package B importa package C;

• package A non importa package C

In questo caso il pacchetto A utilizza C solo transitivamente attraverso il suo


uso di B; cioè, nessun identificatore da C è menzionato nel codice sorgente ad
A, anche se alcuni degli elementi che A sta usando da B menzionano C. Ad
esempio, il pacchetto A potrebbe fare riferimento a un tipo di struttura definito
in B che ha un campo con un tipo definito in C, ma che A non fa riferimento
a se stesso. Per costruire questo programma, in primo luogo, viene compilato
C, poi B e infine A. Questo significa che i pacchetti dipendenti devono essere
compilati prima dei pacchetti che dipendono da essi.
L’effetto sul tempo complessivo di compilazione può essere enorme e si adatta
alle dimensioni della base di codice. Il tempo per eseguire il grafico delle dipen-
denze, e quindi per compilare, può essere esponenzialmente inferiore rispetto
al modello “include” di C e C++. Per rendere la compilazione ancora più ef-
ficiente, il file oggetto è organizzato in modo che i dati di esportazione siano
la prima cosa nel file, in modo che il compilatore possa interrompere la lettura
non appena raggiunge la fine di quella sezione. Questo approccio alla gestione
delle dipendenze è l’unico motivo principale per cui le compilazioni Go sono più
veloci delle compilazioni C o C++.
Un altro fattore è che Go inserisce i dati di esportazione nel file oggetto
o gestore delle dipendenze: alcune lingue richiedono che l’autore scriva o che
il compilatore generi un secondo file con tali informazioni. Questo rende Go

32
ancora più scalabile dal punto dei tool utilizzabili a differenza di Java che ha
bisogno gestori delle dipendeze come Maven o Ant. In Go c’è un solo file da
aprire per importare i pacchetti e framework. Inoltre, l’approccio a file singolo
significa che i dati di esportazione non possono mai essere obsoleti rispetto al
file oggetto.
Un’altra caratteristica del grafico delle dipendenze Go è che non ha cicli.
Sebbene occasionalmente siano utili, le importazioni circolari introducono prob-
lemi significativi su vasta scala. Richiedono al compilatore di gestire set più
grandi di file sorgente contemporaneamente, il che rallenta le build in modo in-
crementale. La mancanza di importazioni circolari provoca fastidio occasionale,
ma mantiene pulito l’albero delle dipendenze, costringendo a una chiara demar-
cazione tra i pacchetti. Come per molte delle decisioni di progettazione in Go,
costringe il programmatore a pensare prima a un problema su larga scala che, se
lasciato a un momento successivo, potrebbe non essere mai affrontato in modo
soddisfacente.
Inoltre bisogna sempre ricordare che può essere meglio copiare un piccolo
codice che inserire una grande libreria per una funzione. L’igiene delle dipen-
denze ha la meglio sul riutilizzo del codice.(17)

3.6 Meno è esponenzialmente meglio


Una delle particolarità per cui Go è conosciuto è la sua assoluta semplicità che
la contraddistingue rispetto agli altri linguaggi di programmazione che risultano
avere una sintassi contorta e difficile da imparare. Difatti Go ha una curva di
apprendimento bassissima, il che la rende adatta ad essere il linguaggio iniziale
per chiunque volesse intraprendere il mondo della programmazione. Per chi
invece conosce già un linguaggio di programmazione, Go risulterà essere vera-
mente facile da apprendere. Questo non è solo dovuto dalla sintassi, bensì dalla
costruzione generale del linguaggio, di ciò che fornisce e dall’ambiente intorno
ad essa.
Partendo dalla sintassi, Go consta solo di 25 keyword:
break default func interface select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var
Inoltre dalla breve documentazione vista nel capitolo 2 si può facilmente
constatare quanto sia comprensibile e priva di elementi superflui. Citandone
alcune troviamo:
• la mancanza del ; alla fine delle istruzioni;
• la visibilità è denotata dalla prima lettera. Se è maiuscola viene definita
pubblica altrimenti è privata
• la creazione di attività concorrenti tramite la keyword go

33
• la mancanza di una keyword per implementare un’interfaccia
• la mancanza dell’ereditarietà
• e molte altre
Il fatto che fornisca un garbage collector permette di eliminare i tempi di
gestione della memoria e possibili errori compiuti dal programmatore come ad
esempio la mancata deallocazione di un elemento in memoria.
Ricordiamo inoltre come Go inciti a uno stile di programmazione pulito come
la mancanza dell’ereditarietà e l’uso di più attività concorrenti, le goroutine, le
quali hanno un metodo di comunicazione semplice come i channel.
Insomma Go è veramente facile da usare, nonostante i difetti e le mancanze
che ha avuto nel corso degli anni come i Generics, introdotti solo recentemente
con la versione di Go 1.18 nel marzo 2022. Abbiamo visto poi la mancanza
dell’overloading dei metodi e la mancanza delle eccezioni portando al program-
matore a pensare e quindi a gestire prima gli errori portando molto spesso ad
avere un codice lungo.
Il titolo di questo sotto-capitolo è ripreso dall’articolo di uno dei creatori di
Go, Rob Pike. Egli afferma che le caratteristiche di Go avrebbero dovuto portare
molti programmatori C++ ad usare il neolinguaggio, dato che in Google che si
programma C++. Questo non accadde in quanto cambiare linguaggio avrebbe
reso vano ottenimento delle difficili abilità ottenute nel corso degli anni. Da
subito, invece, sono arrivati programmatori Python e Ruby i quali non hanno
dovuto rinunciare a troppe caratteristiche peculiare, andando alla ricerca di un
linguaggio in grado di offrire ottime prestazioni e facilità della gestione della
concorrenza.(18)

4 Conclusioni
All’inizio del percorso di stage non avevo nessuna conoscenza di Go e, soprat-
tutto, non conoscevo in chiaro le sue caratteristiche fondamentali. Però non è
stato per niente difficile approcciarmi a questo nuovo linguaggio, anzi. Difatti il
suo apprendimento da parte mia è stato veramente rapido, parlando addirittura
di giorni. Questo ha, inoltre, portato a un rapido sviluppo della Chat, crean-
domi talvolta delle scorciatoie soprattutto per quanto riguarda il problema della
concorrenza.
Ovviamente questo non lo rende il linguaggio migliore al mondo. Infatti, Go
pecca per alcune problematiche citate nel capitolo 3.6 ed altre sollevate dalla
cummunity del linguaggio stesso.
In futuro desidero continuare con lo studio del linguaggio come la gestione
degli errori, i generics e tutte le successive implementazioni portate dalle nuove
versioni.

34
References
[1] Donovan, A. & Kernighan, B. W. The go programming language. In The
Go Programming Language (2015).

[2] Ramanathan, N. Golang tutorial: Table of contents. URL https:


//golangbot.com/learn-golang-series. Last accessed on 2022-05-06.
[3] Mothukuri, R. go build vs go run (2020). URL https://blog.devgenius.
io/go-build-vs-go-run-baa3da9715cc. Last accessed on 2022-05-06.

[4] Radadiya, S. A simple performance test and difference: Go v/s


java (2019). URL https://medium.com/@radadiyasunny970/
a-simple-performance-test-and-difference-go-v-s-java-e6f29ad65293#:
~:text=Go. Last accessed on 2022-05-06.
[5] Pluralsight. An introduction to the go compiler (2016). URL https://www.
pluralsight.com/blog/software-development/the-go-compiler.
Last accessed on 2022-05-06.
[6] freeCodeCamp. Interpreted vs compiled programming lan-
guages (2020). URL https://www.freecodecamp.org/news/
compiled-versus-interpreted-languages/. Last accessed on 2022-05-
06.
[7] LearnTutorials. Go compilazione incrociata. URL https:
//learntutorials.net/it/go/topic/1020/compilazione-incrociata.
Last accessed on 2022-05-06.
[8] Fondoperlaterra. Differenza tra concorrenza e paral-
lelismo (2022). URL https://fondoperlaterra.org/
comdifference-between-concurrency-and-parallelism-38. Last
accessed on 2022-05-06.
[9] Go. Go faq (2022). URL https://go.dev/doc/faq. Last accessed on
2022-05-06.

[10] Hiwarale, U. Achieving concurrency in go (2018). URL https://medium.


com/rungo/achieving-concurrency-in-go-3f84cbf870ca. Last ac-
cessed on 2022-05-06.
[11] Maccioni, D. Concurrency in go (2016). URL https://www.develer.com/
concurrency-in-go/. Last accessed on 2022-05-06.

[12] Go. Go sync (2022). URL https://pkg.go.dev/sync. Last accessed on


2022-05-06.
[13] Oracle. Synchronized methods. URL https://docs.oracle.com/javase/
tutorial/essential/concurrency/syncmeth.html. Last accessed on
2022-05-06.

35
[14] Copes, F. Is go object oriented (2017). URL https://flaviocopes.com/
golang-is-go-object-oriented/. Last accessed on 2022-05-06.
[15] Tramontana, G. La programmazione ad oggetti spiegata in modo sem-
plice (2017). URL https://www.gianlucatramontana.it/2017/12/05/
la-programmazione-ad-oggetti-spiegata-in-maniera-semplice/.
Last accessed on 2022-05-06.
[16] Wikipedia contributors. Multiple inheritance — Wikipedia, the free en-
cyclopedia. https://en.wikipedia.org/w/index.php?title=Multiple_
inheritance&oldid=1083759773 (2022). [Online; accessed 7-May-2022].

[17] Go. Go at google (2018). URL https://go.dev/talks/2012/splash.


article. Last accessed on 2022-05-06.
[18] Pike, R. Less is exponentially more (2012). URL https://commandcenter.
blogspot.com/2012/06/less-is-exponentially-more.html. Last ac-
cessed on 2022-05-06.

36

Potrebbero piacerti anche