O conceito de "referência" e "valor" no JavaScript

Durante uma conversa na comunidade da Formação TS, um dos alunos perguntou sobre um dos temas que eram muito famosos no passado, mas eu mesmo não ouço falar dele faz alguns anos. O conceito de referência vs valor no JavaScript.

Então eu percebi que nunca tinha feito um conteúdo sobre isso e resolvi elaborar mais sobre esse tema.

O que é ref e val?

Referencia e valor são conceitos bem antigos, eu lembro que o Visual Basic tinha duas keywords que permitiam que você escolhesse se o resultado seria um ByVal ou ByRef:

Public Sub ChangeFieldValue(ByVal cls As Class1)
        cls.Field = 500
End Sub

Public Sub ChangeFieldReference(ByRef cls As Class1)
  cls.Field = 500
End Sub

A ideia é bastante simples:

  • Referências apontam para o objeto original como um ponteiro, então alterações em qualquer variável com esse ponteiro, vão alterar o objeto original também
  • Valores não alteram o objeto original porque o valor da variável é um clone do valor original, e não um ponteiro

Mas como isso funciona no JavaScript?

No JavaScript

No JavaScript a gente chama essa galera de Reference Type quando é uma referência ou Value Type para valores. Em suma, tudo que for um tipo primitivo em JS é passado por valor:

  • Numbers
  • Strings
  • Booleans

Sempre que você tiver um desses três tipos, você vai ter uma passagem por valor, ou seja, eles serão clonados quando você enviar a variável de um lugar para outro, por exemplo:

let original = 10

function vezesDois (num) {
  num *= 2
  return num
}

const mutado = vezesDois(original)

console.log(mutado) // 20
console.log(original) // 10

Veja que, mesmo que a gente modifique o valor da variável e associe ela novamente com o valor num, o valor original permaneceu 10, porque num é uma cópia do original.

Agora e com as referências? Todo esse conceito é um pouco diferente.

Vem aprender comigo!

Quer aprender mais sobre as bases do JS e boas práticas com #TypeScript?

Se inscreva na Formação TS!

Referências

Tudo o que não for um primitivo no JS é tratado como um objeto, ou seja, muita coisa no JS é um objeto:

  • Objetos (claro)
  • Funções
  • Arrays
  • null
  • RegExp
  • Classes
  • ...

Quando você cria um objeto no JS de forma literal ou pelo construtor, você está criando um ponteiro para algo chamado de hidden class (eu tenho um artigo só sobre isso, são 10 na verdade). Uma hidden class é um valor, o objeto que você criou aponta para esse valor, mas não vamos entrar em detalhes aqui.

O que é importante saber é que, sempre que você tiver um objeto, quando passarmos o objeto para outra variável ou para outra função, o ponteiro que vai ser passado, e não o valor em si. E isso é extremamente importante porque qualquer alteração no ponteiro, vai alterar o objeto original.

🤔
Você sabia?
Esse é um dos motivos pelo qual podemos usar const x = [] e depois modificar o array mesmo ele sendo constante, pois estamos modificando a referência e não o ponteiro. Mas se tentarmos associar x = [] isso não é possível porque [] é outro objeto e outro ponteiro.

O exemplo mais famoso disso são os métodos de arrays como sort e reverse, que modificam o array original (em conjunto com o push e o pop e muitos outros):

const original = [1, 2, 3, 4, 5]

function foo (arr: any[]) {
  arr.reverse()
}

console.log(original) // [1, 2, 3, 4, 5]
foo(original)
console.log(original) // [5, 4, 3, 2, 1]

Comparação

Um outro ponto importante é quando falamos de comparações usando referências. Quando estamos usando valores, podemos fazer algo assim:

const a = 10
const b = 10
console.log(a===b) // true

O que é totalmente válido visto que a e b tem o mesmo valor. Mas e se a gente fizer assim:

const a = { nome: 'Lucas' }
const b = { nome: 'Lucas' }
console.log(a===b) // false

Isso complica a cabeça de muita gente, tanta gente que eu até fiz um tweet sobre isso:

Mas a realidade é que entender esse problema é bem simples. Lembra que falamos de ponteiros? Então, sempre que você cria um novo objeto com {} ou com o construtor, vamos ter um novo ponteiro, imagine que é algo assim:

const a = {} // ponteiro: 0x89ac (exemplo)
const b = {} // ponteiro: 0x1b3d

console.log(a === b) // 0x89ac === 0x1b3d? false

Claro que ponteiros são um pouco diferentes do que eu mostrei, mas você pegou a ideia. Não podemos comparar objetos porque as referências são diferentes, e só podemos saber se dois objetos são iguais caso a referência de ambos sejam iguais:

const a = {} // ponteiro: 0x89ac (exemplo)
const b = a // ponteiro: 0x89ac

console.log(a === b) // 0x89ac === 0x89ac? true

Cloning

Para sair desse problema e evitar que a gente modifique a variável original, existe o conceito de clonagem. Isso inclusive é um tema bastante interessante porque foi um dos temas do artigo sobre os novos métodos de arrays aqui do blog.

Quando clonamos um objeto, estamos pegando todas as propriedades do objeto original e jogando em outro ponteiro diferente, de forma que se mudarmos esse objeto, não vamos alterar a variável original. O mais comum era fazermos algo assim:

const original = [1, 2, 3, 4, 5]

function foo (arr: any[]) {
  const clone = Object.assign([], arr)
  return clone.reverse()
}

console.log(original) // [1, 2, 3, 4, 5]
const clone = foo(original) // [5, 4, 3, 2, 1]
console.log(original) // [1, 2, 3, 4, 5]

Veja que agora o array original continuou o mesmo. Com o tempo fomos removendo o uso do Object.assign para o uso do StructuredClone, que faz a mesma coisa mas com um twist:

const original = [1, 2, 3, 4, 5]

function foo (arr: any[]) {
  const clone = structuredClone(arr)
  return clone.reverse()
}

console.log(original) // [1, 2, 3, 4, 5]
const clone = foo(original) // [5, 4, 3, 2, 1]
console.log(original) // [1, 2, 3, 4, 5]

O twist é que, se você tem um objeto com outro objeto dentro, usando o Object.assign você só está clonando o ponteiro de fora, porque o próprio objeto tem um ponteiro para outro objeto dentro dele:

const nested = { c: 1 }
const objNested = {
  a: {
    b: nested
  }
}

function modify(obj) {
  obj.a.b.c = 10
}

console.log(objNested.a.b.c) // 1
modify(objNested)
console.log(objNested.a.b.c) // 10

E mesmo com o clone, isso não funciona:

const nested = { c: 1 }
const objNested = {
  a: {
    b: nested
  }
}

function modify(obj) {
  const clone = Object.assign({}, obj)
  clone.a.b.c = 10
}

console.log(objNested.a.b.c) // 1
modify(objNested)
console.log(objNested.a.b.c) // 10

Porque é como se estivéssemos lendo isso aqui:

const nested = { c: 1 } // ponteiro 0x1a
const objNested = { // ponteiro 0x5b
  a: {
    b: nested // objNested.a.b === c -> 0x1a === 0x1a -> true
  }
}

function modify(obj) {
  const clone = Object.assign({}, obj) // clone.a.b é 0x1a
  clone.a.b.c = 10 // estamos modificando o ponteiro de c aqui
}

console.log(objNested.a.b.c) // 1
modify(objNested)
console.log(objNested.a.b.c) // 10

Para funcionar, teríamos que falar que objNested.a.b = Object.assign({}, objNested.a.b.c), isso é simples quando temos um objeto filho, mas quando temos vários, essa função fica complicada. Por conta disso podemos usar o StructuredClone.

Conclusão

Espero que esse breve artigo tenha clarificado as ideias sobre as diferenças principais entre referências e valores com JavaScript, existe muito mais conteúdo legal sobre objetos por ai, inclusive, eu mesmo já escrevi um artigo sobre protótipos que vai te dar uma luz sobre como o JavaScript gerencia métodos e herança por baixo dos panos!

Até mais!