Operador == personalizado, devemos mantê-lo?

Quando o senhor faz isso no Unity:
if (myGameObject == null) {}
A Unity faz algo especial com o operador ==. Em vez do que a maioria das pessoas esperaria, temos uma implementação especial do operador ==.
Isso serve a dois propósitos:
1) Quando um MonoBehaviour tem campos, somente no editor[1], não definimos esses campos como "real null", mas como um objeto "fake null". Nosso operador personalizado == é capaz de verificar se algo é um desses objetos nulos falsos e se comporta de acordo. Embora essa seja uma configuração exótica, ela nos permite armazenar informações no objeto falso null que fornecem mais informações contextuais quando o usuário invoca um método nele ou quando solicita uma propriedade ao objeto. Sem esse truque, o senhor receberia apenas uma NullReferenceException, um rastreamento de pilha, mas não teria ideia de qual GameObjects tinha o MonoBehaviour que tinha o campo que era null. Com esse truque, podemos destacar o GameObjects no inspetor e também lhe dar mais orientações: "parece que o senhor está acessando um campo não inicializado nesse MonoBehaviour aqui, use o inspetor para fazer com que o campo aponte para alguma coisa".
O segundo propósito é um pouco mais complicado.
2) Quando o senhor obtém um objeto c# do tipo "GameObjects"[2], ele não contém quase nada. Isso ocorre porque o Unity é um mecanismo C/C++. Todas as informações reais sobre esse GameObjects (seu nome, a lista de componentes que ele tem, seus HideFlags, etc.) estão no lado c++. A única coisa que o objeto c# tem é um ponteiro para o objeto nativo. Chamamos esses objetos c# de "objetos wrapper". O tempo de vida desses objetos c++, como GameObject e tudo o mais que deriva de UnityEngine.Object, é gerenciado explicitamente. Esses objetos são destruídos quando o senhor carrega uma nova cena. Ou quando o senhor chama Object.Destroy(myObject); neles. A vida útil dos objetos c# é gerenciada da maneira c#, com um coletor de lixo. Isso significa que é possível ter um objeto c# wrapper que ainda existe e que envolve um objeto c++ que já foi destruído. Se o senhor comparar esse objeto com null, nosso operador personalizado == retornará "true" nesse caso, mesmo que a variável c# real não seja realmente null.
Embora esses dois casos de uso sejam bastante razoáveis, a verificação de null personalizada também tem várias desvantagens.
- Isso é contraintuitivo.
- A comparação de dois UnityEngine.Objects entre si ou com null é mais lenta do que o esperado.
- O operador == personalizado não é seguro para thread, portanto, o senhor não pode comparar objetos fora do thread principal. (podemos corrigir esse problema).
- Ele se comporta de forma inconsistente com o operador ??, que também faz uma verificação de null, mas esse faz uma verificação de null pura do c# e não pode ser ignorado para chamar nossa verificação de null personalizada.
Analisando todas essas vantagens e desvantagens, se estivéssemos construindo nossa API do zero, teríamos optado por não fazer uma verificação personalizada de null, mas, em vez disso, teríamos uma propriedade myObject.destroyed que o senhor poderia usar para verificar se o objeto está morto ou não, e apenas conviver com o fato de que não podemos mais fornecer mensagens de erro melhores caso o senhor invoque uma função em um campo que seja null.
O que estamos considerando é se devemos ou não mudar isso. Isso é um passo em nossa busca incessante para encontrar o equilíbrio certo entre "consertar e limpar coisas antigas" e "não quebrar projetos antigos". Nesse caso, queremos saber o que o senhor acha. Para o Unity5, temos trabalhado na capacidade do Unity de atualizar automaticamente seus scripts (mais sobre isso em uma postagem posterior no blog). Infelizmente, não poderíamos atualizar automaticamente seus scripts nesse caso. (porque não podemos distinguir entre "este é um script antigo que realmente deseja o comportamento antigo" e "este é um script novo que realmente deseja o novo comportamento").
Estamos inclinados a "remover o operador personalizado ==", mas estamos hesitantes, pois isso mudaria o significado de todas as verificações de null que os seus projetos fazem atualmente. E para os casos em que o objeto não é "realmente null", mas um objeto destruído, a verificação de null costumava retornar true e, se mudarmos isso, retornará false. Se o senhor quisesse verificar se a sua variável estava apontando para um objeto destruído, precisaria alterar o código para verificar "if (myObject.destroyed) {}". Estamos um pouco nervosos com isso, pois se o senhor não leu este post do blog, e muito provavelmente se leu, é muito fácil não perceber essa mudança de comportamento, especialmente porque a maioria das pessoas não percebe que essa verificação de null personalizada existe.[3]
No entanto, se mudarmos, devemos fazê-lo para o Unity5, pois o limite de dor de atualização com o qual estamos dispostos a fazer os usuários lidarem é ainda mais baixo para versões não principais.
O que o senhor prefere que façamos? Que lhe proporcionemos uma experiência mais limpa, às custas de o senhor ter que alterar null checks em seu projeto, ou que mantenhamos as coisas como estão?
Tchau, Lucas(@lucasmeijer)
[1] Fazemos isso apenas no editor. É por isso que, quando o senhor chama GetComponent() para consultar um componente que não existe, vê uma alocação de memória C# acontecendo, porque estamos gerando essa string de aviso personalizada dentro do objeto falso null recém-alocado. Essa alocação de memória não ocorre em jogos construídos. Esse é um ótimo exemplo de por que, se estiver criando um perfil do seu jogo, o senhor deve sempre criar um perfil do jogador autônomo ou móvel real, e não do editor, pois fazemos muitas verificações extras de segurança/segurança/uso no editor para facilitar a sua vida, às custas de algum desempenho. Ao criar perfis para desempenho e alocações de memória, nunca crie perfis para o editor, sempre crie perfis para o jogo construído.
[2] Isso é verdadeiro não apenas para GameObjects, mas para tudo que deriva de UnityEngine.Object
[3] História engraçada: Deparei-me com isso ao otimizar o desempenho de GetComponent<T>() e, ao implementar algum armazenamento em cache para o componente de transformação, não estava vendo nenhum benefício de desempenho. Em seguida, @jonasechterhoff analisou o problema e chegou à mesma conclusão. O código de cache tem a seguinte aparência:
private Transform m_CachedTransform
public Transform transform
{
get
{
if (m_CachedTransform == null)
m_CachedTransform = InternalGetTransform();
return m_CachedTransform;
}
}
Acontece que dois de nossos próprios engenheiros não perceberam que a verificação de null era mais cara do que o esperado e era a causa de não vermos nenhum benefício de velocidade com o armazenamento em cache. Isso levou à pergunta "bem, se até nós perdemos isso, quantos de nossos usuários perderão?", o que resultou neste blogpost :)