Object Calisthenics em Golang
O termo Object Calisthenics foi introduzido por Jeff Bay e publicado no livro Thought Works Anthology. Trata-se de um conjunto de boas práticas e regras de programação que podem ser aplicadas para melhorar a qualidade do código.
Eu fui apresentado a estas técnicas quando o Rafael Dohms e o Guilherme Blanco fizeram um excelente trabalho adaptando-as da linguagem Java para PHP. Se você escreve código em PHP e ainda não conhece Object Calisthenics eu recomendo duas das apresentações feitas por eles:
Object Calisthenics Applied to PHP
Mas afinal, quais são estas regras? São elas, na versão original:
- One level of indentation per method.
- Don’t use the ELSE keyword.
- Wrap all primitives and Strings in classes.
- First class collections.
- One dot per line.
- Don’t abbreviate.
- Keep all classes less than 50 lines.
- No classes with more than two instance variables.
- No getters or setters.
Como comentei anteriormente, elas foram inicialmente criadas tendo como base a linguagem Java e adaptações são necessárias para outros ambientes. Assim como Rafael e Guilherme fizeram para PHP, é preciso olhar para cada item e analisá-lo para vermos se faz sentido em Go.
O primeiro ponto a considerar é o próprio nome. Calisthenics vem do grego e significa uma série de exercícios para atingir um fim, como melhorar o seu condicionamento físico. Neste contexto os exercícios servem para melhorar o condicionamento do nosso código. O porém é o termo Object pois este conceito não existe em Go. Por isso, proponho uma primeira mudança: de Object Calisthenics para Code Calisthenics. Deixo o espaço de comentários para discutirmos se essa é uma boa sugestão ou não.
Vamos aos demais itens.
One level of indentation per method
Aplicar esta regra permite que o nosso código seja mais legível.
Vamos aplicar a regra ao código:
package chess
import "bytes"
type board struct {
data [][]string
}
func NewBoard(data [][]string) *board {
return &board{data: data}
}
func (b *board) Board() string {
var buffer = &bytes.Buffer{}
// level 0
for i := 0; i < 10; i++ {
// level 1
for j := 0; j < 10; j++ {
// level 2
buffer.WriteString(b.data[i][j])
}
buffer.WriteString("\n")
}
return buffer.String()
}
Um possível resultado, bem mais legível, seria:
package chess
import "bytes"
type board struct {
data [][]string
}
func NewBoard(data [][]string) *board {
return &board{data: data}
}
func (b *board) Board() string {
var buffer = &bytes.Buffer{}
b.collectRows(buffer)
return buffer.String()
}
func (b *board) collectRows(buffer *bytes.Buffer) {
for i := 0; i < 10; i++ {
b.collectRow(buffer, i)
}
}
func (b *board) collectRow(buffer *bytes.Buffer, row int) {
for j := 0; j < 10; j++ {
buffer.WriteString(b.data[row][j])
}
buffer.WriteString("\n")
}
Don’t use the ELSE keyword
A ideia deste item é evitarmos o uso da palavra chave else, gerando um código limpo e mais rápido, pois tem menos fluxos de execução.
Vejamos o código:
package login
import (
"github.com/user/project/user/repository"
)
type loginService struct {
userRepository *repository.UserRepository
}
func NewLoginService() *loginService {
return &loginService{
userRepository: repository.NewUserRepository(),
}
}
func (l *loginService) Login(userName, password string) {
if l.userRepository.IsValid(userName, password) {
redirect("homepage")
} else {
addFlash("error", "Bad credentials")
redirect("login")
}
}
func redirect(page string) {
// redirect to given page
}
func addFlash(msgType, msg string) {
// create flash message
}
Podemos aplicar o conceito Early Return e remover o else da função Login:
func (l *loginService) Login(userName, password string) {
if l.userRepository.IsValid(userName, password) {
redirect("homepage")
return
}
addFlash("error", "Bad credentials")
redirect("login")
}
Wrap all primitives and Strings in classes
Esta regra sugere que os tipos primitivos que possuem comportamento devem ser encapsulados, no nosso caso, em structs ou types e não em classes. Desta forma, a lógica do comportamento fica encapsulado e de fácil manutenção. Vamos ver o exemplo:
package ecommerce
type order struct {
pid int64
cid int64
}
func CreateOrder(pid int64, cid int64) order {
return order{
pid: pid, cid: cid,
}
}
func (o order) Submit() (int64, error) {
// do some logic
return int64(3252345234), nil
}
Aplicando a regra, usando structs:
package ecommerce
import (
"strconv"
)
type order struct {
pid productID
cid customerID
}
type productID struct {
id int64
}
// some methods on productID struct
type customerID struct {
id int64
}
// some methods on customerID struct
type orderID struct {
id int64
}
func (oid orderID) String() string {
return strconv.FormatInt(oid.id, 10)
}
// some other methods on orderID struct
func CreateOrder(pid int64, cid int64) order {
return order{
pid: productID{pid}, cid: customerID{cid},
}
}
func (o order) Submit() (orderID, error) {
// do some logic
return orderId{int64(3252345234)}, nil
}
Outra possível refatoração, mais idiomática, usando types poderia ser:
package ecommerce
import (
"strconv"
)
type order struct {
pid productID
cid customerID
}
type productID int64
// some methods on productID type
type customerID int64
// some methods on customerID type
type orderID int64
func (oid orderID) String() string {
return strconv.FormatInt(int64(oid), 10)
}
// some other methods on orderID type
func CreateOrder(pid int64, cid int64) order {
return order{
pid: productID(pid), cid: customerID(cid),
}
}
func (o order) Submit() (orderID, error) {
// do some logic
return orderID(int64(3252345234)), nil
}
Além de ficar mais legível o código alterado permite fácil evolução das regras de negócio e maior segurança em relação ao que estamos manipulando.
First class collections
Se você tiver um conjunto de elementos e quiser manipulá-los, crie uma estrutura dedicada para essa coleção. Assim comportamentos relacionados à coleção serão implementados por sua própria estrutura como filtros, uma regra a cada elemento e etc.
Dado o código:
package contact
import "fmt"
type person struct {
name string
friends []string
}
type friend struct {
name string
}
func NewPerson(name string) *person {
return &person{
name: name,
friends: []string{},
}
}
func (p *person) AddFriend(name string) {
p.friends = append(p.friends, name)
}
func (p *person) RemoveFriend(name string) {
new := []string{}
for _, friend := range p.friends {
if friend != name {
new = append(new, friend)
}
}
p.friends = new
}
func (p *person) GetFriends() []string {
return p.friends
}
func (p *person) String() string {
return fmt.Sprintf("%s %v", p.name, p.friends)
}
Pode ser refatorado para:
package contact
import (
"strings"
"fmt"
)
type friends struct {
data []string
}
type person struct {
name string
friends *friends
}
func NewFriends() *friends {
return &friends{
data: []string{},
}
}
func (f *friends) Add(name string) {
f.data = append(f.data, name)
}
func (f *friends) Remove(name string) {
new := []string{}
for _, friend := range f.data {
if friend != name {
new = append(new, friend)
}
}
f.data = new
}
func (f *friends) String() string {
return strings.Join(f.data, " ")
}
func NewPerson(name string) *person {
return &person{
name: name,
friends: NewFriends(),
}
}
func (p *person) AddFriend(name string) {
p.friends.Add(name)
}
func (p *person) RemoveFriend(name string) {
p.friends.Remove(name)
}
func (p *person) GetFriends() *friends {
return p.friends
}
func (p *person) String() string {
return fmt.Sprintf("%s [%v]", p.name, p.friends)
}
One dot per line
Essa regra cita que você não deve encadear funções e sim usar as que fazem parte do mesmo contexto.
Exemplo:
package chess
type piece struct {
representation string
}
type location struct {
current *piece
}
type board struct {
locations []*location
}
func NewLocation(piece *piece) *location {
return &location{current: piece}
}
func NewPiece(representation string) *piece {
return &piece{representation: representation}
}
func NewBoard() *board {
locations := []*location{
NewLocation(NewPiece("London")),
NewLocation(NewPiece("New York")),
NewLocation(NewPiece("Dubai")),
}
return &board{
locations: locations,
}
}
func (b *board) squares() []*location {
return b.locations
}
func (b *board) BoardRepresentation() string {
var buffer = &bytes.Buffer{}
for _, l := range b.squares() {
buffer.WriteString(l.current.representation[0:1])
}
return buffer.String()
}
Neste caso vamos refatorar para:
package chess
import "bytes"
type piece struct {
representation string
}
type location struct {
current *piece
}
type board struct {
locations []*location
}
func NewPiece(representation string) *piece {
return &piece{representation: representation}
}
func (p *piece) character() string {
return p.representation[0:1]
}
func (p *piece) addTo(buffer *bytes.Buffer) {
buffer.WriteString(p.character())
}
func NewLocation(piece *piece) *location {
return &location{current: piece}
}
func (l *location) addTo(buffer *bytes.Buffer) {
l.current.addTo(buffer)
}
func NewBoard() *board {
locations := []*location{
NewLocation(NewPiece("London")),
NewLocation(NewPiece("New York")),
NewLocation(NewPiece("Dubai")),
}
return &board{
locations: locations,
}
}
func (b *board) squares() []*location {
return b.locations
}
func (b *board) BoardRepresentation() string {
var buffer = &bytes.Buffer{}
for _, l := range b.squares() {
l.addTo(buffer)
}
return buffer.String()
}
Esta regra reforça o uso da “Lei de Demeter”:
Converse apenas com seus amigos imediatos
Don’t abbreviate
Esta regra é uma das que não se aplica diretamente a Go. A comunidade tem suas próprias regras para a criação de nomes de variáveis, inclusive razões por usarmos nomes menores. Recomendo a leitura deste capítulo do ótimo Practical Go: Real world advice for writing maintainable Go programs
Keep all classes less than 50 lines
Apesar de não existir o conceito de classes em Go, a ideia desta regra é que as entidades sejam pequenas. Podemos adaptar a ideia para criarmos structs e interfaces pequenas e que podem ser usadas, via composição, para formar componentes maiores. Por exemplo, a interface:
type Repository interface {
Find(id entity.ID) (*entity.User, error)
FindByEmail(email string) (*entity.User, error)
FindByChangePasswordHash(hash string) (*entity.User, error)
FindByValidationHash(hash string) (*entity.User, error)
FindByChallengeSubmissionHash(hash string) (*entity.User, error)
FindByNickname(nickname string) (*entity.User, error)
FindAll() ([]*entity.User, error)
Update(user *entity.User) error
Store(user *entity.User) (entity.ID, error)
Remove(id entity.ID) error
}
Pode ser refatorada para:
type Reader interface {
Find(id entity.ID) (*entity.User, error)
FindByEmail(email string) (*entity.User, error)
FindByChangePasswordHash(hash string) (*entity.User, error)
FindByValidationHash(hash string) (*entity.User, error)
FindByChallengeSubmissionHash(hash string) (*entity.User, error)
FindByNickname(nickname string) (*entity.User, error)
FindAll() ([]*entity.User, error)
}
type Writer interface {
Update(user *entity.User) error
Store(user *entity.User) (entity.ID, error)
Remove(id entity.ID) error
}
type Repository interface {
Reader
Writer
}
Desta forma, as interfaces Reader e Writer podem ser reutilizadas por outras interfaces e cenários.
No classes with more than two instance variables
Esta regra não parece fazer sentido em Go, mas se tiver alguma sugestão por favor compartilhe.
No Getters/Setters
Assim como a regra anterior, esta também não parece ser adaptável a Go pois não é um costume da comunidade usar este recurso, como pode ser visto neste tópico do Efetive Go. Mas sugestões de adaptação são bem-vindas.
Aplicando estas regras, entre outras boas práticas, é possível termos um código mais limpo, simples, performático e fácil de manter. Adoraria saber suas opiniões sobre as regras e desta sugestão de adaptação para a linguagem.
Referências
https://javflores.github.io/object-calisthenics/
https://williamdurand.fr/2013/06/03/object-calisthenics/
https://medium.com/web-engineering-vox/improving-code-quality-with-object-calisthenics-aa4ad67a61f1
Alguns exemplos usados neste post foram adaptados a partir do repositório: https://github.com/rafos/object-calisthenics-in-go
Agradecimentos
Obrigado Wagner Riffel e Francisco Oliveira pelas sugestões de melhorias quanto aos exemplos.