design de apis
workshop bitcake 2022
workshop bitcake 2022
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.
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 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.
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
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 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.
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.
a única maneira de simplificar um problema inerenentemente complexo é resolvendo outro problema (que seja mais simples)
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!
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)
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!
erros e limitações serão encontrados independente da quantidade de design investido a priori. aceite-os e planeje-se!
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
começou super flexível porém laborioso
foi inclusive latente o problema de usabilidade uma vez que o alan tentou abstrair a flexibilidade do sistema
exemplo
reconhecendo o caso comum, otimizamos o processo
entendendo melhor a forma como o sistema seria usado, foi possível otimizar a usabilidade
exemplo
inclusive, a forma simples é implementada por cima da flexível
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 fácil de deletar é aquele que outros não dependem
exemplo
umake v2 vs v3
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
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
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.
- 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
enfim do que é feita uma boa api! criar abstrações que adicionam valor substancial ao sistema, à solução e aos usuários.
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
conceitos são independentes e não se influenciam
conceitos são ortogonais até não serem mais
profunda
ortogonal
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
quão fácil é a interação com outras abstrações?
(mais prático quando há um canal uniforme que integra sistemas)
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
exemplo: unix pipeline
ls -l | grep key | less
faca de dois legumes: facilita a comunicação entre sistemas, limita a comunicação entre sistemas
composível
preferir api imediata
callbacks adicionam indireção ao fluxo de código
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
// 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!
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
no início, queremos simplicidade
no final, queremos controle total
"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
class UNekoDialogueCharacterDataAsset : public UDataAsset {
ENekoDialogueCharacterId Id = ENekoDialogueCharacterId::None;
TArray<TSoftObjectPtr<UTexture2D>> Expressions;
// implementado com for
UTexture2D* GetExpression(ENekoDialogueCharacterExpression Expression);
};
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
class UNekoDialogueCharacterDataAsset : public UDataAsset {
ENekoDialogueCharacterId Id = ENekoDialogueCharacterId::None;
TSoftObjectPtr<UTexture2D> ExpressionDefault;
TSoftObjectPtr<UTexture2D> ExpressionAngry;
TSoftObjectPtr<UTexture2D> ExpressionHappy;
// implementado com switch
UTexture2D* GetExpression(ENekoDialogueCharacterExpression Expression);
};
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
abstrações devem puxar mais que seu próprio peso
entender os tradeoffs feitos
profunda
complexidade adicionada < complexidade abstraída
interface enxuta
almejar criar um "pit of success" pra quem for usar
interface enxuta
padrões razoáveis
extrair dados estáticos em arquivos separados
separar dado que não muda/config em arquivos separados pra facilitar alteração, organização e controle
interface enxuta
padrões razoáveis
arrays muito provavelmente são suficientes
processadores adoram memória linear
*olha feio praquela lista encadeada* 😠
interface enxuta
sobre quais os dados a api opera?
onde eles são alterados?
não é sobre anarquia! :B facilitar entendimento e reflexão por diminuir "partes móveis".
interface enxuta
estado mínimo
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);
}
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
quem é o responsável pelo recurso?
posse vs empréstimo
onde há alocação de memória? possível evitá-la?
interface enxuta
+ pré-condições
+ pós-condições
uma propriedade do sistema que é sempre verdade. ex: esse ponteiro sempre aponta para memória válida.
interface enxuta
invariantes explícitas
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)
Option<T>interface enxuta
invariantes explícitas
dois tipos de erros:
qualquer outro tipo de erro é desnecessário
exemplo
open "folder/some file.txt
# ops, esqueci de fechar aspas ^
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
usar tipos para codificar valores válidos
// evitar
int GetPlayerCount();
// preferir
uint8 GetPlayerCount();
mesma coisa também para parâmetros de funções e membros de structs/classes.
interface enxuta
a partir de uma transformação conhecida, é possível inferir aproximadamente suas entradas e saídas
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
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
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)
}
}
}
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
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();
}
}
}
vários detalhes omitidos clássica implementação orientada a objetos
`length` é serializado no MonoBehaviour callbacks são configurados no Start/Awake
exemplo
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;
}
}
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
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;
}
}
}
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.
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
"everything should be made as simple as possible, but not simpler."
"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