Implementação de campos privados para JavaScript
Ao implementar um recurso de idioma para JavaScript, um implementador deve tomar decisões sobre como o idioma nos mapas de especificação para a implementação. Às vezes isso é bastante simples, onde a especificação e a implementação podem compartilhar muito da mesma terminologia e algoritmos. Outras vezes, as pressões na implementação tornam-na mais desafiadora, exigindo ou pressionando a estratégia de implementação divergir para divergir da especificação da linguagem.
Campos privados é um exemplo de onde a linguagem de especificação e a realidade de implementação divergem, pelo menos no SpiderMonkey, o motor JavaScript que alimenta o Firefox. Para entender mais, explicarei quais são os campos privados, alguns modelos para pensar neles e explicar por que nossa implementação diverge da linguagem de especificação.
Campos Privados
Campos privados são um recurso de idioma que está sendo adicionado à linguagem JavaScript através do processo de proposta TC39, como parte da proposta de campos de classe, que está no Estágio 4 no processo TC39. Enviaremos campos privados e métodos privados no Firefox 90.
A proposta de campos privados adiciona uma noção estrita de "estado privado" à língua. No exemplo a seguir, só pode ser acessado por instâncias de classe:#x
A
class A {
#x = 10;
}
Isso significa que fora da classe, é impossível acessar esse campo. Ao contrário dos campos públicos, por exemplo, como mostra o exemplo a seguir:
class A {
#x = 10; // Private field
y = 12; // Public Field
}
var a = new A();
a.y; // Accessing public field y: OK
a.#x; // Syntax error: reference to undeclared private field
Mesmo várias outras ferramentas que o JavaScript lhe dá para interrogar objetos são impedidas de acessar campos privados (por exemplo. não listam campos privados; não há como usar para acessá-los).Object.getOwnProperty{Symbols,Names}
Reflect.get
Uma característica de três maneiras
Quando se fala de um recurso no JavaScript, muitas vezes há três aspectos diferentes em jogo: o modelo mental, a especificação e a implementação.
O modelo mental fornece o pensamento de alto nível que esperamos que os programadores usem principalmente. A especificação, por sua vez, fornece o detalhe da semântica exigida pelo recurso. A implementação pode parecer muito diferente do texto de especificação, desde que a semântica de especificação seja mantida.
Esses três aspectos não devem produzir resultados diferentes para as pessoas que raciodem as coisas (embora, às vezes, um "modelo mental" é abreviação, e não captura com precisão a semântica em cenários de borda).
Podemos olhar para campos privados usando esses três aspectos:
Modelo Mental
O modelo mental mais básico que se pode ter para campos privados é o que diz na lata: campos, mas privados. Agora, os campos JS tornam-se propriedades em objetos, então o modelo mental é talvez "propriedades que não podem ser acessadas de fora da classe".
No entanto, quando encontramos proxies, este modelo mental quebra um pouco; tentar especificar a semântica para 'propriedades ocultas' e proxies é desafiador (o que acontece quando um Proxy está tentando fornecer controle de acesso às propriedades, se você não deveria ser capaz de ver campos privados com Proxies? As subclasses podem acessar campos privados? Os campos privados participam da herança de protótipos?) . A fim de preservar as propriedades de privacidade desejadas, um modelo mental alternativo tornou-se a maneira como o comitê pensa sobre campos privados.
Este modelo alternativo é chamado de modelo 'WeakMap'. Neste modelo mental você imagina que cada classe tem um mapa fraco escondido associado a cada campo privado, de tal forma que você poderia hipoteticamente 'desugar'
class A {
#x = 15;
g() {
return this.#x;
}
}
em algo como
class A_desugared {
static InaccessibleWeakMap_x = new WeakMap();
constructor() {
A_desugared.InaccessibleWeakMap_x.set(this, 15);
}
g() {
return A_desugared.InaccessibleWeakMap_x.get(this);
}
}
O modelo não é, surpreendentemente, como o recurso é escrito na especificação, mas é uma parte importante da intenção de design por trás deles. Vou cobrir um pouco mais tarde como este modelo mental aparece em lugares mais tarde.WeakMap
Especificação
As alterações de especificação reais são fornecidas pela proposta dos campos de classe, especificamente as alterações no texto de especificação. Não cobrirei cada pedaço deste texto de especificação, mas chamarei aspectos específicos para ajudar a elucidar as diferenças entre o texto de especificação e a implementação.
Primeiro, a especificação adiciona a noção de [[PrivateName]]
, que é um identificador de campo globalmente único. Essa singularidade global é garantir que duas classes não possam acessar os campos um do outro apenas tendo o mesmo nome.
function createClass() {
return class {
#x = 1;
static getX(o) {
return o.#x;
}
};
}
let [A, B] = [0, 1].map(createClass);
let a = new A();
let b = new B();
A.getX(a); // Allowed: Same class
A.getX(b); // Type Error, because different class.
A especificação também adiciona um novo 'slot interno', que é uma peça de nível de especificação de estado interno associada a um objeto na especificação, chamado [[PrivateFieldValues]]
a todos os objetos. é uma lista de registros do formulário:[[PrivateFieldValues]]
{
[[PrivateName]]: Private Name,
[[PrivateFieldValue]]: ECMAScript value
}
Para manipular esta lista, a especificação adiciona quatro novos algoritmos:
Esses algoritmos funcionam em grande parte como seria de esperar: anexa uma entrada na lista (embora, no interesse de tentar fornecer erros ansiosamente, se um nome privado correspondente já existir na lista, ele lançará um . Vou mostrar como isso pode acontecer depois). recupera um valor armazenado na lista, digitado por um nome privado dado, etc.PrivateFieldAdd
TypeError
PrivateFieldGet
O truque de substituição do construtor
Quando comecei a ler a especificação, fiquei surpreso ao ver que podia lançar. Dado que ele só foi chamado de um construtor sobre o objeto que está sendo construído, eu esperava plenamente que o objeto seria recém-criado, e, portanto, você não precisaria se preocupar com um campo que já estava lá.PrivateFieldAdd
Isso acaba sendo possível, um efeito colateral de algumas das especificações de manipulação dos valores de retorno do construtor. Para ser mais concreto, o seguinte é um exemplo me dado por André Bargull, que mostra isso em ação.
class Base {
constructor(o) {
return o; // Note: We are returning the argument!
}
}
class Stamper extends Base {
#x = "stamped";
static getX(o) {
return o.#x;
}
}
Stamper
é uma classe que pode 'carimbar' seu campo privado em qualquer objeto:
let obj = {};
new Stamper(obj); // obj now has private field #x
Stamper.getX(obj); // => "stamped"
Isso significa que quando adicionamos campos privados a um objeto, não podemos supor que ele ainda não os tenha. É aqui que entra em jogo o check-in pré-existência:PrivateFieldAdd
let obj2 = {};
new Stamper(obj2);
new Stamper(obj2); // Throws 'TypeError' due to pre-existence of private field
Essa capacidade de carimbar campos privados em objetos arbitrários interage um pouco com o modelo WeakMap aqui também. Por exemplo, dado que você pode carimbar campos privados em qualquer objeto, isso significa que você também pode carimbar um campo privado em um objeto selado:
var obj3 = {};
Object.seal(obj3);
new Stamper(obj3);
Stamper.getX(obj3); // => "stamped"
Se você imaginar campos privados como propriedades, isso é desconfortável, porque significa que você está modificando um objeto que foi selado por um programador para modificação futura. No entanto, usando o modelo de mapa fraco, é totalmente aceitável, pois você está usando apenas o objeto selado como uma chave no mapa fraco.
PS: Só porque você pode carimbar campos privados em objetos arbitrários, não significa que você deve: Por favor, não faça isso.
Implementação da Especificação
Diante da implementação da especificação, há uma tensão entre seguir a letra da especificação e fazer algo diferente para melhorar a implementação em alguma dimensão.
Onde é possível implementar as etapas da especificação diretamente, preferimos fazer isso, pois facilita a manutenção dos recursos à medida que as alterações de especificação são feitas. SpiderMonkey faz isso em muitos lugares. Você verá seções de código que são transcrições de algoritmos de especificação, com números de passos para comentários. Seguir a letra exata da especificação também pode ser útil onde a especificação é altamente complexa e pequenas divergências podem levar a riscos de compatibilidade.
Às vezes, porém, há boas razões para divergir da linguagem de especificação. As implementações do JavaScript têm sido aprimoradas para alto desempenho há anos, e há muitos truques de implementação que foram aplicados para que isso aconteça. Às vezes reformular uma parte da especificação em termos de código já escrito é a coisa certa a fazer, pois isso significa que o novo código também é capaz de ter as características de desempenho do código já escrito.
Implementação de nomes privados
A linguagem de especificação para Nomes Privados já quase corresponde à semântica em torno de Símbolos
, que já existem no SpiderMonkey. Então, adicionar como um tipo especial de é uma escolha bastante fácil.PrivateNames
Symbol
Implementação de Campos Privados
Olhando para a especificação para campos privados, a implementação da especificação seria adicionar um slot oculto extra a cada objeto no SpiderMonkey, que contém uma referência a uma lista de pares. No entanto, implementar isso diretamente tem uma série de desvantagens claras:{PrivateName, Value}
- Ele adiciona uso de memória a objetos sem campos privados
- Requer adição invasiva de novos bytecodes ou complexidade a caminhos de acesso a propriedades sensíveis ao desempenho.
Uma opção alternativa é divergir da linguagem de especificação, e implementar apenas a semântica, não os algoritmos de especificação reais. Na maioria dos casos, você realmente pode pensar em campos privados como propriedades especiais em objetos que estão escondidos de reflexão ou introspecção fora de uma classe.
Se modelarmos campos privados como propriedades, em vez de uma lista lateral especial que é mantida com um objeto, podemos aproveitar o fato de que a manipulação de propriedades já é extremamente otimizada em um motor JavaScript.
No entanto, as propriedades estão sujeitas à reflexão. Então, se modelarmos campos privados como propriedades de objetos, precisamos garantir que as APIs de reflexão não as revelem, e que você não possa ter acesso a eles via Proxies.
No SpiderMonkey, optou-se por implementar campos privados como propriedades ocultas, a fim de aproveitar todas as máquinas otimizadas que já existem para propriedades no motor. Quando comecei a implementar esse recurso, André Bargull – colaborador da SpiderMonkey por muitos anos – realmente me entregou uma série de patches que tinham uma boa parte da implementação de campos privados já feitas, pelo qual fiquei extremamente grato.
Usando nossos símbolos especiais do PrivateName, nós efetivamente desuagar
class A {
#x = 10;
x() {
return this.#x;
}
}
para algo que parece mais próximo de
class A_desugared {
constructor() {
this[PrivateSymbol(#x)] = 10;
}
x() {
return this[PrivateSymbol(#x)];
}
}
No entanto, os campos privados têm semânticas ligeiramente diferentes das propriedades. Eles são projetados para emitir erros em padrões que se espera que sejam erros de programação, em vez de aceitá-lo silenciosamente. Por exemplo:
- Acessando uma propriedade em um objeto que não tem ele retornado . Campos privados são especificados para lançar um , como resultado do algoritmo
PrivateFieldGet
.undefined
TypeError
- Definir uma propriedade em um objeto que não o tenha simplesmente adiciona a propriedade. Campos privados jogarão um
em PrivateFieldSet
.TypeError
- Adicionar um campo privado a um objeto que já tem esse campo também lança um
em PrivateFieldAdd
. Consulte "O Truque de Substituição de Construtor" acima para saber como isso pode acontecer.TypeError
Para lidar com as diferentes semânticas, modificamos a emissão de bytecode para acessos de campo privado. Adicionamos uma nova operação de bytecode, que verifica que um objeto tem o estado correto para um determinado campo privado. Isso significa lançar uma exceção se a propriedade estiver faltando ou presente, conforme apropriado para Obter/Definir ou Adicionar. é emitido pouco antes de usar o caminho regular 'nome da propriedade computada' (aquele usado para ).CheckPrivateField
CheckPrivateField
A[someKey]
CheckPrivateField
é projetado de tal forma que podemos facilmente implementar um cache inline usando CacheIR. Uma vez que estamos armazenando campos privados como propriedades, podemos usar a forma de um objeto como um guarda, e simplesmente retornar o valor booleano apropriado. A forma de um objeto no SpiderMonkey determina quais propriedades ele tem e onde eles estão localizados no armazenamento para esse objeto. Objetos que têm a mesma forma são garantidos para ter as mesmas propriedades, e é um cheque perfeito para um IC para .CheckPrivateField
Outras modificações que fizemos para fazer ao motor incluem omitir campos privados do protocolo de enumeração da propriedade, e permitir a extensão de objetos selados se estivermos adicionando campo privado.
Proxies
Proxies nos apresentou um novo desafio. Concretamente, usando a classe acima, você pode adicionar um campo privado diretamente a um Proxy:Stamper
let obj3 = {};
let proxy = new Proxy(obj3, handler);
new Stamper(proxy)
Stamper.getX(proxy) // => "stamped"
Stamper.getX(obj3) // TypeError, private field is stamped
// onto the Proxy Not the target!
Eu definitivamente achei isso surpreendente inicialmente. A razão pela qual eu achei isso surpreendente foi que eu esperava que, como outras operações, a adição de um campo privado iria túnel através do proxy para o alvo. No entanto, uma vez que pude internalizar o modelo mental weakmap, pude entender esse exemplo muito melhor. O truque é que no modelo WeakMap, é o objeto alvo, usado como a chave no WeakMap.Proxy
#x
Essas semânticas apresentaram um desafio à nossa escolha de implementação para modelar campos privados como propriedades ocultas, no entanto, como os Proxies do SpiderMonkey são objetos altamente especializados que não têm espaço para propriedades arbitrárias. Para apoiar este caso, adicionamos um novo slot reservado para um objeto 'expando'. O expando é um objeto alocado preguiçosamente que atua como titular para propriedades dinamicamente adicionadas no proxy. Esse padrão já é usado para objetos DOM, que normalmente são implementados como objetos C++ sem espaço para propriedades extras. Então, se você escrever, isso aloca um objeto expando para , e coloca a propriedade e o valor lá em vez disso. Voltando a campos privados, quando é acessado em um Proxy, o código proxy sabe ir e olhar no objeto expando para essa propriedade.document.foo = "hi"
document
foo
#x
Em Conclusão
Private Fields é um exemplo de implementação de um recurso de linguagem JavaScript onde implementar diretamente a especificação como escrita seria menos performante do que re-lançar a especificação em termos de primitivos de motor já otimizados. No entanto, essa reformulação em si pode exigir alguma resolução de problemas não presente na especificação.
No final, estou bastante feliz com as escolhas feitas para a nossa implementação do Private Fields, e estou animado em vê-lo finalmente entrar no mundo!
Agradecimentos
Tenho que agradecer, mais uma vez, ao André Bargull, que providenciou o primeiro conjunto de patches e deu uma excelente trilha para eu seguir. Seu trabalho tornou o acabamento de campos privados muito mais fácil, já que ele já tinha pensado muito na tomada de decisões.
Jason Orendorff tem sido um excelente e paciente mentor como eu trabalhei através desta implementação, incluindo duas implementações separadas do bytecode de campo privado, bem como duas implementações separadas de suporte proxy.
Graças a Caroline Cullen, e Iain Ireland por ajudar a ler rascunhos deste post, e a Steve Fink por corrigir muitos erros de digitação.
Comentários
Postar um comentário