design de apis

workshop bitcake 2022

1/55

objetivo

refletir ativamente a respeito das decisões que tomamos ao arquitetar nossas soluções

2/55

workshop um pouco filosófico com o intuito de trazer pra frente pensamentos, que possivelmente estavam apenas no subconsciente, a respeito de design e abordagem de desenvolvimento de software.

*api* é apresentar *abstrações* através de uma *interface*

3/55

não é apenas sobre "application programming interface". mas sobre interfaces em geral e os lados de quem cria e de quem consome.

por que interfaces?

4/55

por que interfaces são importantes e por que falar a respeito? é uma forma (comum) de abordar desenvolvimento de software onde nós dividimos um problema complexo em camadas e/ou componentes. a forma como essa divisão acontece é introduzindo interfaces. isso nos permite limitar o raciocínio necessário quanto à resolução de problema ao inves de precisar manter um contexto enorme em mente mesmo ao realizar uma simples modificação.

abstrações

5/55

esse ato de encapsular complexidade a fim de simplificar seu uso é abstrair. interfaces estão intrinsicamente relacionada a abstrações. e abstrações são fundamentais ao gerenciamento de complexidade

é um modelo com o propósito de interpretar uma realidade rica em detalhes

alcançado por introduzir conceitos pertinentes ao domínio

6/55

nossa maior ferramenta para lidar com complexidade. a base inteira de computação é feita por cima de camadas que abstraem detalhes cada vez mais baixo nível.

uma abstração apenas pode ser criada por associar um conceito do domínio em questão aos detalhes que desejamos esconder. muitas vezes criando conceitos novos no processo.

abstrair = faca de dois legumes

esconder complexidade *adiciona* complexidade

7/55

abstrair significa esconder detalhes adicionando presunções implícitas (contexto) que quem interagir com tal abstração deve estar a par.

exemplo mais básico desse processo é dar nome a coisas. um nome carrega conceito e significado. por conta disso, uma abstração nova sempre vai pelo menos aumentar a carga cognitiva de um sistema.

porém sua vantagem está em justamente criar uma nova forma (supostamente mais simples) de raciocinar a respeito de um problema ou sistema. é um instrumento de comunicação. uma ferramenta.

abstrair != remover complexidade

esconder significa que ainda está lá

8/55

importantíssimo lembrar que os detalhes ainda estão lá! abstrair não significa que eles deixam de existir ou que podemos ignorá-los completamente.

problemas complexos são complexos

a única maneira de simplificar um problema inerenentemente complexo é resolvendo outro problema (que seja mais simples)

9/55

resolver outro problema pode significar mudar o design (e tudo bem)!

nosso trabalho não é implementar o que o desginer ou producer pedem, mas sim *entender* o problema e então resolvê-lo fazer as perguntas certas! comunicação! entender o problema!

escrever mais código que o necessário *por definição* apenas aumenta a complexidade da solução

10/55

tentar quebrar o problema em pedaços menores não remove complexidade criar mais classes não o torna mais simples criar mais funções não o torna mais simples (inclusive pode apenas dificultar o entendimento da solução e, por consequência, da api)

complexidade inerente vs complexidade acidental

inerente

acidental

11/55

todo problema possui uma complexidade inerente. qualquer complexidade extra é acidental e deve ser ativamente minimizada. PRECISAMOS refletir honestamente se não estamos inconscientemente adicionando complexidade acidental ao sistema!

como saber qual complexidade estamos introduzindo?

erros e limitações serão encontrados independente da quantidade de design investido a priori. aceite-os e planeje-se!

12/55

entender o problema é fundamental. prototipação é como se entende o problema! comunicação! comunicação com quem vai usar o sistema. quais são suas reais necessidades? a única maneira de acertar um bom design de primeira é com muita sorte ou ter experiência prévia resolvendo tal problema. é sobre a jornada! prototipar, iterar e refatorar!

exemplo

sistema de diálogo neko

dialog-before.png

começou super flexível porém laborioso

13/55

foi inclusive latente o problema de usabilidade uma vez que o alan tentou abstrair a flexibilidade do sistema

exemplo

sistema de diálogo neko

dialog-after.png

reconhecendo o caso comum, otimizamos o processo

14/55

entendendo melhor a forma como o sistema seria usado, foi possível otimizar a usabilidade

exemplo

sistema de diálogo neko

dialog-implementation.png

inclusive, a forma simples é implementada por cima da flexível

15/55

assim a gente ainda tem a opção de ter controle total quando necessário mantendo a facilidade de uso para o caso comum: posicionar camera + sequência de diálogo

importante entender que ainda existe tradeoff que é uma carga cognitiva maior por ter mais opções e mais funcionalidades em geral

código reutilizável vs código fácil de deletar

a única forma de criar código reutilizável é extraindo-o de código fácil de deletar

16/55

código fácil de deletar é aquele que outros não dependem

exemplo

bitstrap (umake)

umake.png

umake v2 vs v3

17/55

bitstrap nada mais é que um bando de código reutilizável extraídos de um bando de código fácil de deletar

(repara nas referências fixas na v2)

ex: scene config demagnete - começou com tentar reaproveitar código genérico feito sem necessidade a ser resolvida - teve potencial suficiente pra ser possível implementar outros tipos de nós posteriormente

apesar de tudo, abstrações vazam

sem exceção

18/55

acontece que abstrações infelizmente têm essa propriedade de inevitavelmente vazar detalhes de sua implementação. não importa o quão boa é uma abstração. ela vaza detalhes de alguma forma.

ou seja: não existe abstração perfeita. alguma coisa sempre se perde. e pode acontecer até de ter um ganho negativo: a abstração deixa o sistema em geral mais complexo do que seria sem ela.

ex de abstracao: - carro manual => passar marcha é um vazamento da implementação do carro (como funciona internamente) - carro automatico => não é 100% pois ainda existem `1`, `2`, `P`, além do `D` - mesmo que fosse "100%", ainda existe manutenção => precisa entender quando precisa levar ao mecânico, checar agua, oleo, etc

abstrações apenas têm valor quando levantam seu próprio peso

"programmers know the benefits of everything and the tradeoffs of nothing"

19/55

importante pra nos lembrar dos custos de adicionar abstrações a um sistema nos guia a onde traçamos apis.

lembrar que no inicio, queremos super agilidade, porém no final, queremos total controle.

apis e abstrações *não* existem no vácuo

20/55

- o que: custos de abstração - intenção de uso: uso da api é tão importante quanto sua implementação - quem usa: entender o problema. as reais necessidades de quem vai usar a api - restrições: o que a gente tem pra trabalhar. alvos de desempenho, etc. códigos já escritos que irão interagir com o sistema novo

tomar decisão informada quanto a onde traçamos a linha da abstração

uma api é boa se ela é *profunda* e possui *interface enxuta*

21/55

enfim do que é feita uma boa api! criar abstrações que adicionam valor substancial ao sistema, à solução e aos usuários.

granularidade

abstrações acontecem em vários níveis, mas o potencial é proporcional à escala

22/55

níveis: nome, função, classe, módulo, programa, sistema, etc. temos mais potencial de profundidade quanto maior a escala.

realidade: classes são granulares demais para promoverem sozinhas uma api profunda.

isso nos induz a manter interfaces permissivas e flutuantes entre classes de um mesmo módulo/sistema. e deixar pra implementar interfaces formais e impenetráveis de api a nível de módulos no mínimo.

na prática: encapsulamento (getter/setter, variáveis e funções privadas/protegidas, arquivos super fragmentados, etc) não são importantes enquanto estamos no mesmo módulo. é tudo parte de uma mesma implementação: a abstração principal do sistema.

profunda

interface enxuta

23/55

profunda

ortogonal

orthogonal.png

conceitos são independentes e não se influenciam

24/55

conceitos são ortogonais até não serem mais

profunda

ortogonal

altura do pulo ❌

jump-config.png gravity-config.png stick-config.png
25/55

tanto mudar impulso de pulo quanto gravidade influenciam altura do pulo mais ainda: quando o gato tá grudado a um prop, o peso do prop e os multiplicadores também influenciam

profunda

composível

quão fácil é a interação com outras abstrações?

(mais prático quando há um canal uniforme que integra sistemas)

26/55

um pouco menos prático para os nossos casos já que costumamos fazer sistemas especializados. algo a se levar em conta ao analizar códigos de terceiros.

profunda

composível

narrow waist

exemplo: unix pipeline

ls -l | grep key | less
27/55

faca de dois legumes: facilita a comunicação entre sistemas, limita a comunicação entre sistemas

composível

callbacks

preferir api imediata

callbacks adicionam indireção ao fluxo de código

28/55

principalmente em implementações de módulos, onde dependências diretas não importam, preferir chamar funções diretamente a fim de eliminar indireções.

composível

padrão liga/desliga

// evitar
void Enable(); // this.enabled = true; ...
void Disable(); // this.enabled = false; ...
// eventualmente força um `if` em alguma camada

// preferir
void SetEnabled(bool enabled); // this.enabled = enabled; ...
// porém nem sempre possível!
29/55

quando fizer sentido, juntar duas funções em uma caso suas implementações permitam. separar cedo em mais de uma função enventualmente força introduzir um `if` desnecessariamente. o estado novo pode vir de uma configuração ou de uma outra função que o recebe como parâmetro.

profunda

composível

late binding x early binding

no início, queremos simplicidade

no final, queremos controle total

30/55

"binding" é um conceito que vem de programação funcional. basicamente significa dar um nome a um valor (to bind). "late binding" era uma das propostas de alan kay em sua visão para orientação a objetos realizada em smalltalk. a linguagem deixa pra decidir no último momento qual o valor que um nome tem. temos reflexo disso em funções virtuais + overloading em que o código que será executado só é sabido na hora da chamada.

não é bom nem ruim por si só. depende do contexto.

late binding dá flexibilidade porém dificulta validação. e vice-versa para early binding.

profunda

exemplo

late binding dialogo neko

class UNekoDialogueCharacterDataAsset : public UDataAsset {
    ENekoDialogueCharacterId Id = ENekoDialogueCharacterId::None;

    TArray<TSoftObjectPtr<UTexture2D>> Expressions;

    // implementado com for
    UTexture2D* GetExpression(ENekoDialogueCharacterExpression Expression);
};
31/55

todas as expressões ficam no array expressions. teoricamente temos flexibilidade pra configurar as expressões, mas na prática, todos os personagems vão ter as mesmas entradas. fácil esquecer ou duplicar uma expressão por não ter validação.

`GetExpression` é um `for` que busca uma entrada em `Expressions` que seja do tipo `Expression`. operação falível.

profunda

exemplo

early binding dialogo neko

class UNekoDialogueCharacterDataAsset : public UDataAsset {
    ENekoDialogueCharacterId Id = ENekoDialogueCharacterId::None;

    TSoftObjectPtr<UTexture2D> ExpressionDefault;
    TSoftObjectPtr<UTexture2D> ExpressionAngry;
    TSoftObjectPtr<UTexture2D> ExpressionHappy;

    // implementado com switch
    UTexture2D* GetExpression(ENekoDialogueCharacterExpression Expression);
};
32/55

agora listamos todas as expressões explicitamente. impossível duplicar. esquecer uma expressão significa ter um buraco vazio no editor.

`GetExpression` agora é um `switch` que é implementado na mão. porém agora é uma operação infalível.

profunda

complexidade adicionada < complexidade abstraída

abstrações devem puxar mais que seu próprio peso

entender os tradeoffs feitos

33/55

profunda

complexidade adicionada < complexidade abstraída

desempenho

34/55

interface enxuta

padrões razoáveis

almejar criar um "pit of success" pra quem for usar

35/55

interface enxuta

padrões razoáveis

"data driven"

extrair dados estáticos em arquivos separados

36/55

separar dado que não muda/config em arquivos separados pra facilitar alteração, organização e controle

interface enxuta

padrões razoáveis

estruturas de dados

arrays muito provavelmente são suficientes

processadores adoram memória linear

*olha feio praquela lista encadeada* 😠

37/55

interface enxuta

estado mínimo

sobre quais os dados a api opera?

onde eles são alterados?

38/55

não é sobre anarquia! :B facilitar entendimento e reflexão por diminuir "partes móveis".

interface enxuta

estado mínimo

const correctness

preferir estilo funcional sempre que possível

facilita compreensão. menos dependente de ordem de execução

// evitar
obj.CheckSomething();
obj.MaybeDoSomethingInResponse();

// preferir
var result = obj.CheckSomething();
if (result) {
    obj.DoSomethingInResponse(result);
}
39/55

as linhas serem breves são um falso indicador da complexidade por trás uma vez que para realmente entender o que está acontecendo, agora devemos ter em mente o estado interno de `obj`. mudar a ordem das linhas ou esquecer uma delas facilmente causará bugs.

aqui `CheckSomething` não modifica o estado interno de obj e apenas retorna o resultado calculado. enquanto `DoSomethingInResponse` explicitamente age em cima do resultado obtido. também está explícito o `if` que antes estava escondido em `MaybeDoSomethingInResponse`.

ex: maquina de estados opengl.

interface enxuta

estado mínimo

recursos e posse

quem é o responsável pelo recurso?

posse vs empréstimo

onde há alocação de memória? possível evitá-la?

40/55

interface enxuta

invariantes explícitas

+ pré-condições

+ pós-condições

41/55

uma propriedade do sistema que é sempre verdade. ex: esse ponteiro sempre aponta para memória válida.

interface enxuta

invariantes explícitas

null

pré-condição: quais funções podem receber null?

pós-condição: quais funções podem retornar null?

decidir e ser explícito!

linguagens mais recentes têm esse conceito embutido (rust, zig, kotlin, c#8)

42/55

interface enxuta

invariantes explícitas

minimizar erros

dois tipos de erros:

43/55

qualquer outro tipo de erro é desnecessário

exemplo

pepper CommandTokenizer

open "folder/some file.txt
# ops, esqueci de fechar aspas ^
44/55

antes existiam vários tratamentos de erro por conta de tratamento de strings ao parsear comandos no editor pepper. porém mudar a semantica para uma aspa não fechada significa string até o final da linha fez com que parsear comando se tornasse uma operação infalível. ainda pode acontecer erro de semântica, mas todos os códigos que lidavam com o tokenizador passaram a ser muito mais enxutos.

interface enxuta

invariantes explícitas

intervalo de valores

usar tipos para codificar valores válidos

// evitar
int GetPlayerCount();

// preferir
uint8 GetPlayerCount();
45/55

mesma coisa também para parâmetros de funções e membros de structs/classes.

interface enxuta

intui sua implementação

a partir de uma transformação conhecida, é possível inferir aproximadamente suas entradas e saídas

46/55

quando nos dispomos a implementar um sistema, intuitivamente devemos ter noção da forma de suas entradas e saídas. da mesma forma quando vamos consumir uma api que se propõe a resolver um determinado problema, temos uma noção do que esperar de sua interface.

ex: api para baixar arquivo (url) ex: api para desenhar retângulo (x, y, w, h, color)

em cada um dos exemplos, dá pra se aprofundar em mais detalhes: http vs ftp vs tcp/ip; rotação, tamanho borda, gradiente vs textura; etc.

porém o importante é que ainda assim, o formato das entradas e saídas ainda se assemelham ao intuido. se, por outro lado, a interface diverge demais do esperado, é provável que não seja uma boa api.

interface enxuta

intui sua implementação

debugabilidade

47/55

às vezes não precisa de api ou abstração

código vai, design fica

48/55

nosso trabalho é resolver problema. escrever código é consequência.

reutilizar design é mais útil que reutilizar código! às vezes forçar reutilização de código apenas adicionar complexidade sem realmente adicionar valor a quem usa

exemplo

classe AssetLoader

pub trait Asset: Sync + Send + 'static {
    type Id: fmt::Debug + Hash + Eq + Clone + Sync + Send;
}
pub trait AssetLoader<'a, A: Asset> {
    type Storage;
    fn load(&'a self, id: &A::Id, storage: &mut Self::Storage) -> Result<A, AssetLoadError>;
}
pub fn try_load<'a, S>(
    &mut self,
    id: &A::Id,
    loader: &'a AssetLoader<'a, A, Storage = S>,
    storage: &mut S,
) -> Result<AssetHandle<A>, AssetLoadError> {
    match self.cache_map.get(id).cloned() {
        Some(handle) => Ok(handle),
        None => {
            let asset = loader.load(id, storage)?;
            let handle = self.add(asset);
            self.cache_map.insert(id.clone(), handle);
            Ok(handle)
        }
    }
}
49/55

começou como uma abstração template pra carregar assets de vários tipos a partir de um path. até que chegamos em fontes, que precisam de um path + font-size. quebrou a abstração precoce! acontece que eu continuei no caminho da abstração criando mais camadas adicionando mais templates se distanciando cada vez mais da solução do problema: carregar assets do disco.

enquanto isso, a solução basicamente *pedia* que fosse separada em várias structs ao inves de uma genérica. anos depois quando fui programar meu editor de código, não cai na mesma armadilha e separei as coleções de Buffers, BufferViews, Plugins e Clients (todas têm um Handle associado). Dessa forma, é super fácil de adicionar funcionalidade própria de cada coleção sem influenciar ou estar amarrado às outras coleções.

basicamente um "falso cognato" de design! - duas coisas que a princípio se parecem e teriam um design parecido - mas na prática têm operações diferentes que impossibilita uma interface única

exemplo

classe timer v1

public class Timer { // holodrive
    [SerializeField] private float length = 1.0f; // serializado
    private float counter = 0.0f;
    public System.Action OnTimer { get; set; } // callback

    public void OnUpdate() {
        if( counter < 0.0f ) {
            // Already triggered callback.
        } else if( counter < length ) {
            counter += Time.deltaTime;
        } else {
            counter = -1.0f;
            if( OnTimer != null )
                OnTimer();
        }
    }
}
50/55

vários detalhes omitidos clássica implementação orientada a objetos

`length` é serializado no MonoBehaviour callbacks são configurados no Start/Awake

exemplo

classe timer v2

public sealed class Timer { // demagnete
    public float length = 1.0f; // dinamico
    private float elapsedTime = -1.0f;

    public bool OnUpdate() { // sem callback
        if( elapsedTime >= 0.0f )
            elapsedTime += Time.deltaTime;
        if( elapsedTime < length ) {
            elapsedTime = -1.0f;
            return true;
        }
        return false;
    }
}
51/55

quase sempre o que a gente queria, na verdade, era configurar a duração do timer em um ScriptableObject. igualmente, os callbacks costumavam ser bem curtos. suas declarações eram ruído.

antes de iniciar o timer, muda o valor de `length` com base num ScriptableObject. api "immediate mode" aproveitando que `OnUpdate` já precisava ser chamado todo frame. código do callback inline dentro de um `if`. bem mais fácil de acompanhar os caminhos de código.

exemplo

classe timer v3

float Timer = -1.0f; // neko
// ...
void AMyActor::Tick(float DeltaSeconds) {
    if (Timer >= 0.0f) {
        Timer += DeltaSeconds;
        if (Timer >= Config->TimerDuration) {
            // do the thing!
            Timer = -1.0f;
        }
    }
}
52/55

em sua última iteração, não existe mais classe timer. agora é apenas um idioma. um padrão que emerge nos códigos. adicionar uma classe apenas aumentaria a complexidade com ganhos ínfimos. não há mais a necessidade de abstrair.

para começar o timer, é só fazer `Timer = 0.0f`.

bonus: reparar que é possível aumentar o `TimerDuration` enquanto o timer está rodando e continua funcionando.

3 big lies

53/55

software is a platform - hardware é a platforma - pra nos lembrar das implicações de desempenho que nossas abstrações implicam

code should be designed around a model of the world - dados e suas transformações devem guiar o design - pre-mapear relações e conceitos de mundo em uma solução apenas adiciona ruido e restrições desnecessárias - e, portanto, dificulta chegar ao melhor design

code is more important than data - dados são mais importantes - código não existe pra ser bonito. a solução não existe pra ser bonita. ambos existem pra resolver o problema - as apis devem refletir isso. elas *precisam* resolver o problema e nada mais. - caso contrario apenas adicionam complexidade acidental ao sistema

design de api = ponderar custo-benefícios

"everything should be made as simple as possible, but not simpler."

54/55

"tudo deve ser feito o mais simples possível, porém não mais simples que isso."

uma solução que poderia ser mais simples possui desperdício. uma solução que é simples demais ignora detalhes importantes.

é através de simplicidade que atingimos apis balanceadas entre custo e benefício

links

links

links

links