Struct para gerenciar Tags no C# - Parte 2
Vitor Luiz Rubio
Posted on February 10, 2023
Então estamos fazendo uma struct para trabalhar com Tags no C#. No primeiro artigo fizemos aquela primeira tentativa que não ficou muito boa e tinha apenas 4 testes. Agora vamos começar as melhorias, mais testes e a descrição do processo.
Iteração 1
Mudamos a List interna para um HashSet porque o hashset já garante a unicidade das tags. Fizemos a renomeação de algumas vairáveis, e mais 9 testes. 3 de criação, 3 de add e 3 de remove.
Deixamos a HashSet como readonly, para não mudarmos sua instância, mas mesmo assim ela (e todo o restante), é mutável.
Deixamos a ordenação só para a saída ToString.
Já podemos criar Tags a partir de strings usando um dos constructores ou convertê-las para strings, mas ainda não podemos simplesmente atribuir um objeto tags a uma string, ou uma string ao Tags. Também não temos o que é recomendável pela microsoft: Override de Equals, GetHashCode, etc.
Igualdade entre tags com o mesmo conteúdo, como se fossem um record, Equal(), GetHashCode(), ==, nada disso está funcionando.
Iteração 2
Adicionamos mais esses testes ao que foi feito na iteração 1 e todos passaram:
[TestMethod]
public void CanCreateTagsFromList()
{
Tags tags = new Tags(new List<string> { "tag1", "tag2", "tag3" });
tags.AddTags(new List<string> { "tag4", "tag5", "tag3" });
Assert.AreEqual("tag1,tag2,tag3,tag4,tag5", tags.ToString());
}
[TestMethod]
public void CanCreateTagsFromString()
{
Tags tags = new Tags("tag1, tag2, tag3");
tags.AddTags("tag4, tag5, tag3");
Assert.AreEqual("tag1,tag2,tag3,tag4,tag5", tags.ToString());
}
[TestMethod]
public void CanCreateTagsFromTags()
{
Tags tags = new Tags();
tags.AddTags(new Tags("tag1, tag2, tag3"));
tags.AddTags(new Tags("tag4, tag5, tag3"));
Tags newTags = new Tags(tags);
Assert.AreEqual("tag1,tag2,tag3,tag4,tag5", newTags.ToString());
}
[TestMethod]
public void CanAddTagsFromList()
{
Tags tags = new Tags();
tags.AddTags(new List<string> { "tag1", "tag2", "tag3" });
tags.AddTags(new List<string> { "tag4", "tag5", "tag3" });
Assert.AreEqual("tag1,tag2,tag3,tag4,tag5", tags.ToString());
}
[TestMethod]
public void CanAddTagsFromString()
{
Tags tags = new Tags();
tags.AddTags("tag1, tag2, tag3");
tags.AddTags("tag4, tag5, tag3");
Assert.AreEqual("tag1,tag2,tag3,tag4,tag5", tags.ToString());
}
[TestMethod]
public void CanAddTagsFromTags()
{
Tags tags = new Tags();
tags.AddTags(new Tags("tag1, tag2, tag3"));
tags.AddTags(new Tags("tag4, tag5, tag3"));
Assert.AreEqual("tag1,tag2,tag3,tag4,tag5", tags.ToString());
}
[TestMethod]
public void CanRemoveTagsFromList()
{
Tags tags = new Tags("tag1,tag2,tag3,tag4,tag5");
tags.RemoveTags(new List<string> { "tag1", "tag5"});
Assert.AreEqual("tag2,tag3,tag4", tags.ToString());
}
[TestMethod]
public void CanRemoveTagsFromString()
{
Tags tags = new Tags("tag1,tag2,tag3,tag4,tag5");
tags.RemoveTags("tag1, tag5");
Assert.AreEqual("tag2,tag3,tag4", tags.ToString());
}
[TestMethod]
public void CanRemoveTagsFromTags()
{
Tags tags = new Tags("tag1,tag2,tag3,tag4,tag5");
tags.RemoveTags(new Tags("tag1, tag5"));
Assert.AreEqual("tag2,tag3,tag4", tags.ToString());
}
Criamos testes que falham com certeza, alguns deles nem compilam por isso a parte que não compila está comentada para vermos os outros falharem:
[TestMethod]
public void TagsWithSameContentsShouldBeEquals()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Tags tags2 = new Tags("tag1,tag2,tag3,tag4,tag5");
Assert.AreEqual(tags1, tags2);
Assert.IsTrue(tags1.Equals(tags2));
//Assert.IsTrue(tags1 == tags2); //não compila
}
[TestMethod]
public void SameTagsShouldBeEquals()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Tags tags2 = tags1;
Assert.AreEqual(tags1, tags2);
Assert.IsTrue(tags1.Equals(tags2));
//Assert.IsTrue(tags1 == tags2); //não compila
}
[TestMethod]
public void TagsWithSameContentsShouldHaveSameHashcodeEquals()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Tags tags2 = new Tags("tag1,tag2,tag3,tag4,tag5");
Assert.AreEqual(tags1.GetHashCode(), tags2.GetHashCode());
}
[TestMethod]
public void SameTagsShouldHaveSameHashcodeEquals()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Tags tags2 = tags1;
Assert.AreEqual(tags1.GetHashCode(), tags2.GetHashCode());
}
[TestMethod]
public void TagsShouldBeEqualsToString()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Assert.AreEqual("tag1,tag2,tag3,tag4,tag5", tags1);
Assert.IsTrue(tags1.Equals("tag1,tag2,tag3,tag4,tag5"));
//Assert.IsTrue(tags1 == "tag1,tag2,tag3,tag4,tag5"); //não compila
}
Iteração 3
Os testes que criamos falharam porque não temos override do Equals, nem do GetHashCode, ou do operador ==
Também precisamos dar uma arrumada na casa, está tudo em um arquivo só porque fizemos no replit, mas está na hora de separar o projeto de teste do restante, a Tags para uma biblioteca e Product para uma suposta aplicação.
Também estou passando o nome de todas as classes de domínio para o português para fins didáticos, e passando a Tags para uma biblioteca chamada SharpTags para simular uma biblioteca de terceiros (que é a maneira como outros a usariam)
Criamos a biblioteca SharpTags
Renomeamos TagStructureTest para SharpTagsTest
Ficamos com a estrutura:
TagStructureTest
├─> Dominio
│ ├── Dominio.csproj
│ └── Produto.cs
├─> SharpTags
│ ├── SharpTags.csproj
│ └── Tags.cs
├─> TagStructureTest
│ ├── TagsTest.cs
│ └── TagStructureTest.csproj
├── TagStructureTest.sln
Adicionamos Override do Equals e do GetHashCode, que sempre devem ser implementados juntos.
O Override do GetHashCode eu simplesmente aproveitei que já temos um ToString e uso a hashcode que seria gerada para sua string. Não acredito que precisamos de algo melhor que isso por enquanto.
Já o Equals, primeiro ele verifica se dois objetos são o mesmo objeto/instância e retorna true, caso contrário verifica se o objeto sendo comparado é null e retorna false, por último ele vê se as duas strings resultantes são iguais, retornando esse resultado.
Não vamos entrar em detalhes sobre o GetHashCode, ele é um algritmo que gera um número inteiro único para um objeto e é usado para otimizar a performance ao armazenar esse objeto em hashes, como listas do tipo HashSet e Dictionary, fazendo com que sejam armazenados como se fosse em um vetor indexado numericamente (usando esse número gerado como índice) para evitar colisões e aumentar a performance em casoss de listas muito grandes.
A regra mais simples é: se dois objetos são iguais então seus hashes devem ser iguais. Se você fez o override de Equals é obrigado a fazer o override de GetHashCode.
Se você estivesse trabalhando com entidades aqui para serem persistidas em banco de dados com nHibernate ou EF, você faria o GetHashCode ser o próprio Id, e faria o Equals ser baseado no próprio Id também.
- Definição e Guidelines para implementar Equals
- Implementação correta de GetHashCode
- Usar o Visual Studio para gerar o Equals e o GetHashCode
- Discussão interessante sobre GetHashCode no Stack Overflow
Minha implementação:
public override int GetHashCode()
{
return this.ToString().GetHashCode();
}
public override bool Equals(object? obj)
{
if (object.ReferenceEquals(this, obj))
{
return true;
}
if (obj == null)
{
return false;
}
if ((!(obj is Tags)) && (!(obj is string)))
{
return false;
}
return this.ToString().Equals(obj.ToString());
}
Quase todos passaram exceto os ainda comentados e o teste Assert.AreEqual("tag1,tag2,tag3,tag4,tag5", tags1);
Iteração 4
Para iniciar a iteração 4 vamos fazer o operator overloading dos sinais == e !=
Operator overloading é um tipo de método que sobrecarrega ou motifica o comportamento de operadores. São úteis para quando precisamos que uma classe ou até uma struct se comporte como um tipo de dado especial até nos momentos em que usamos == ou !=. Por exemplo, nos objetos Produto os operadores == e != só comparam as referências e não o conteúdo. Nós alteraremos esse comportamento em Tags porque queremos que o conteúdo das tags seja considerado.
Veja também
Algumas mudanças foram feitas porque estamos tratando de igualdade e override de operators em um Value Type e não em um Reference Type.
Isso torna algumas coisas mais simples embora outras precisem de mais cuidados.
O trecho de código abaixo podemos tirar:
if (object.ReferenceEquals(this, obj))
{
return true;
}
Com Value Types não precisamos lidar com Reference Equals nem com nulidade.
Os operadores == e != implementados. Veja que o != é facil, porque ele é a negação do ==.
public static bool operator ==(Tags esquerda, Tags direita)
{
return esquerda.Equals(direita);
}
public static bool operator !=(Tags esquerda, Tags direita) => !(esquerda == direita);
Agora podemos descomentar as linhas:
Assert.IsTrue(tags1 == tags2);
mas a Assert.IsTrue(tags1 == "tag1,tag2,tag3,tag4,tag5");
ainda não compila, segura ela comentada.
Podemos colocar esses métodos em outros testes também.
O teste TagsShouldBeEqualsToString continua não passando, porque compara com string, ainda não implementamos isso.
Acrescentei mais 4 testes:
[TestMethod]
public void EqualityOperatorSameVarTest()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Tags tags2 = tags1;
Assert.IsTrue(tags1 == tags2);
}
[TestMethod]
public void EqualityOperatorSameContentsTest()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Tags tags2 = new Tags("tag1,tag2,tag3,tag4,tag5");
Assert.IsTrue(tags1 == tags2);
}
[TestMethod]
public void InequalityOperatorSameVarTest()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Tags tags2 = tags1;
tags2.AddTags("Teste");
Assert.IsTrue(tags1 != tags2);
}
[TestMethod]
public void IneEqualityOperatorSameContentsTest()
{
Tags tags1 = new Tags("tag1,tag2,tag3,tag4,tag5");
Tags tags2 = new Tags("tag1,tag2,tag3,tag4,tag6");
Assert.IsTrue(tags1 != tags2);
}
E não estranhamente o InequalityOperatorSameVarTest não passou. Porque? Porque ambos estão compartilhando a mesma HashSet _taglist, por referência, que está sendo mudada pelo método AddTags. Podemos mudar esse método para criar uma nova, mas isso fará novamente com que outros testes, principalmente de retorno de funções e getters retornando Tags (como no caso de Produto), falhem.
A solução é transformar a classe em imutável de vez. Isso vai envolver bastante energia por isso deixaremos para a iteração 5.
Posted on February 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.