Compartilhar via


Membros necessários

Nota

Este artigo é uma especificação de recurso. A especificação serve como o documento de design para o recurso. Ele inclui alterações de especificação propostas, juntamente com as informações necessárias durante o design e o desenvolvimento do recurso. Esses artigos são publicados até que as alterações de especificação propostas sejam finalizadas e incorporadas na especificação ECMA atual.

Pode haver algumas discrepâncias entre a especificação do recurso e a implementação concluída. Essas diferenças são capturadas nas notas pertinentes da reunião de design de idioma (LDM).

Você pode saber mais sobre o processo de adoção de speclets de recursos no padrão de linguagem C# no artigo sobre as especificações de .

Resumo

Esta proposta adiciona uma maneira de especificar que uma propriedade ou campo deve ser definido durante a inicialização do objeto, forçando o criador da instância a fornecer um valor inicial para o membro em um inicializador de objeto no site de criação.

Motivação

As hierarquias de objetos hoje exigem muito código repetitivo para transportar dados em todos os níveis da hierarquia. Vamos examinar uma hierarquia simples envolvendo um Person, como pode ser definido em C# 8:

class Person
{
    public string FirstName { get; }
    public string MiddleName { get; }
    public string LastName { get; }

    public Person(string firstName, string lastName, string? middleName = null)
    {
        FirstName = firstName;
        LastName = lastName;
        MiddleName = middleName ?? string.Empty;
    }
}

class Student : Person
{
    public int ID { get; }
    public Student(int id, string firstName, string lastName, string? middleName = null)
        : base(firstName, lastName, middleName)
    {
        ID = id;
    }
}

Há muita repetição aqui:

  1. Na raiz da hierarquia, o tipo de cada propriedade tinha que ser repetido duas vezes e o nome tinha que ser repetido quatro vezes.
  2. No nível derivado, o tipo de cada propriedade herdada tinha que ser repetido uma vez e o nome tinha que ser repetido duas vezes.

Esta é uma hierarquia simples com 3 propriedades e 1 nível de herança, mas muitos exemplos reais desses tipos de hierarquias vão muitos níveis mais profundos, acumulando um número cada vez maior de propriedades que são repassadas à medida que isso ocorre. Roslyn é uma dessas bases de código, por exemplo, nos vários tipos de árvore que formam nossos CSTs e ASTs. Esse aninhamento é tão entediante que temos geradores de código para criar os construtores e definições desses tipos, e muitos clientes adotam abordagens semelhantes para resolver o problema. O C# 9 apresenta registros, que para alguns cenários podem melhorar:

record Person(string FirstName, string LastName, string MiddleName = "");
record Student(int ID, string FirstName, string LastName, string MiddleName = "") : Person(FirstName, LastName, MiddleName);

records elimina a primeira fonte de duplicação, mas a segunda fonte de duplicação permanece inalterada: infelizmente, essa é a fonte de duplicação que cresce conforme a hierarquia cresce e é a parte mais dolorosa da duplicação para corrigir após fazer uma alteração na hierarquia, pois exige verificar todos os lugares na hierarquia, possivelmente até mesmo em diferentes projetos, e possivelmente causando problemas para os consumidores.

Como uma solução alternativa para evitar essa duplicação, há muito tempo, vemos os consumidores adotando inicializadores de objetos como uma forma de evitar escrever construtores. Antes do C# 9, no entanto, isso tinha 2 principais desvantagens:

  1. A hierarquia de objetos deve ser totalmente mutável, com acessadores set em cada propriedade.
  2. Não há como garantir que cada instanciação de um objeto do grafo defina cada membro.

O C# 9 novamente abordou o primeiro problema aqui, introduzindo o acessador init: com ele, essas propriedades podem ser definidas na criação/inicialização do objeto, mas não posteriormente. No entanto, ainda temos o segundo problema: as propriedades em C# são opcionais desde o C# 1.0. Tipos de referência anuláveis, introduzidos no C# 8.0, resolveram parte desse problema: se um construtor não inicializar uma propriedade de tipo de referência não anulável, o usuário será avisado sobre ele. No entanto, isso não resolve o problema: o usuário aqui deseja não repetir partes grandes do tipo no construtor, ele deseja passar o requisito para definir propriedades para os consumidores. Ele também não fornece avisos sobre ID em Student, pois esse é um tipo de valor. Esses cenários são extremamente comuns em ORMs de modelo de banco de dados, como o EF Core, que precisa ter um construtor público sem parâmetros, mas, em seguida, determina a nulabilidade das linhas com base na nulabilidade das propriedades.

Esta proposta busca resolver essas preocupações introduzindo um novo recurso ao C#: membros necessários. Os membros exigidos deverão ser inicializados pelos consumidores, e não pelo autor do tipo, com diversas personalizações para permitir flexibilidade em múltiplos construtores e outros cenários.

Design detalhado

tipos de class, structe record ganham a capacidade de declarar um required_member_list. Esta lista é a lista de todas as propriedades e campos de um tipo que são considerados necessários e devem ser inicializados durante a construção e inicialização de uma instância do tipo. Os tipos herdam essas listas de seus tipos base automaticamente, fornecendo uma experiência perfeita que remove código clichê e repetitivo.

Modificador required

Adicionamos 'required' à lista de modificadores em field_modifier e property_modifier. O required_member_list de um tipo é composto por todos os membros que tiveram required aplicados. Portanto, o tipo de Person anterior agora tem esta aparência:

public class Person
{
    // The default constructor requires that FirstName and LastName be set at construction time
    public required string FirstName { get; init; }
    public string MiddleName { get; init; } = "";
    public required string LastName { get; init; }
}

Todos os construtores em um tipo que tem um required_member_list automaticamente anunciam um contrato que os consumidores desse tipo devem inicializar todas as propriedades listadas. É um erro para um construtor anunciar um contrato que exige um membro que não seja pelo menos tão acessível quanto o construtor em si. Por exemplo:

public class C
{
    public required int Prop { get; protected init; }

    // Advertises that Prop is required. This is fine, because the constructor is just as accessible as the property initer.
    protected C() {}

    // Error: ctor C(object) is more accessible than required property Prop.init.
    public C(object otherArg) {}
}

required só é válido em tipos class, structe record. Não é válido em tipos de interface. required não pode ser combinado com os seguintes modificadores:

  • fixed
  • ref readonly
  • ref
  • const
  • static

required não tem permissão para ser aplicada aos indexadores.

O compilador emitirá um aviso quando Obsolete for aplicado a um membro necessário de um tipo e:

  1. O tipo não está indicado como Obsolete, ou
  2. Qualquer construtor ao qual não foi atribuído SetsRequiredMembersAttribute não está marcado como Obsolete.

SetsRequiredMembersAttribute

Todos os construtores em um tipo com membros necessários ou cujo tipo base especifica os membros necessários devem ter esses membros definidos por um consumidor quando esse construtor é chamado. Para isentar construtores desse requisito, um construtor pode ser associado com SetsRequiredMembersAttribute, o que elimina essas exigências. O corpo do construtor não é validado para garantir que ele definitivamente defina os membros necessários do tipo.

SetsRequiredMembersAttribute remove todos os requisitos de de um construtor, e a validade desses requisitos não é verificada de forma alguma. NB: essa é o hatch de escape se a herança de um tipo com uma lista de membros necessária inválida for necessária: marque o construtor desse tipo com SetsRequiredMembersAttribute, e nenhum erro será relatado.

Se um construtor C está encadeado a um construtor base ou this que possui o atributo SetsRequiredMembersAttribute, C também deverá possuir o atributo SetsRequiredMembersAttribute.

Para tipos de registro, emitiremos SetsRequiredMembersAttribute no construtor de cópia sintetizado de um registro se o tipo de registro ou qualquer um dos tipos base tiver membros necessários.

NB: uma versão anterior desta proposta possuía uma metalinguagem mais abrangente envolvendo a inicialização, permitindo a adição e remoção de membros individuais necessários de um construtor, assim como a validação de que o construtor estava definindo todos os membros exigidos. Isso foi considerado muito complexo para a versão inicial e removido. Podemos analisar a adição de contratos e modificações mais complexos como um recurso posterior.

Execução

Para cada construtor Ci no tipo T com membros obrigatórios R, os consumidores que chamam Ci devem escolher uma das seguintes ações:

  • Configure todos os membros de R em um object_initializer na object_creation_expression,
  • Ou defina todos os membros de R por meio da seção named_argument_list de um attribute_target.

a menos que Ci seja atribuído a SetsRequiredMembers.

Se o contexto atual não permitir um object_initializer ou não for um attribute_targete Ci não for atribuído com SetsRequiredMembers, será um erro chamar Ci.

Restrição new()

Um tipo com um construtor sem parâmetros que anuncia um contrato não tem permissão para ser substituído por um parâmetro de tipo restrito a new(), pois não há como a instanciação genérica garantir que os requisitos sejam atendidos.

struct defaults

Os membros obrigatórios não são impostos em instâncias de tipos de struct criados com default ou default(StructType). Essas regras são aplicadas para instâncias de struct criadas com new StructType(), mesmo quando StructType não tem construtor sem parâmetros e o construtor de struct padrão é utilizado.

Acessibilidade

É um erro marcar um membro obrigatório se o membro não puder ser definido em nenhum contexto em que o tipo de contenção esteja visível.

  • Se o membro for um campo, ele não poderá ser readonly.
  • Se o membro for uma propriedade, ele deverá ter um setter ou um inicializador pelo menos tão acessível quanto o tipo que o contém.

Isso significa que os seguintes casos não são permitidos:

interface I
{
    int Prop1 { get; }
}
public class Base
{
    public virtual int Prop2 { get; set; }

    protected required int _field; // Error: _field is not at least as visible as Base. Open question below about the protected constructor scenario

    public required readonly int _field2; // Error: required fields cannot be readonly
    protected Base() { }

    protected class Inner
    {
        protected required int PropInner { get; set; } // Error: PropInner cannot be set inside Base or Derived
    }
}
public class Derived : Base, I
{
    required int I.Prop1 { get; } // Error: explicit interface implementions cannot be required as they cannot be set in an object initializer

    public required override int Prop2 { get; set; } // Error: this property is hidden by Derived.Prop2 and cannot be set in an object initializer
    public new int Prop2 { get; }

    public required int Prop3 { get; } // Error: Required member must have a setter or initer

    public required int Prop4 { get; internal set; } // Error: Required member setter must be at least as visible as the constructor of Derived
}

É um erro ocultar um membro required, pois esse membro não pode mais ser definido por um consumidor.

Ao substituir um membro required, a palavra-chave required deve ser incluída na assinatura do método. Isso é feito para que, se um dia quisermos deixar de exigir uma propriedade com uma substituição no futuro, possamos ter a flexibilidade de design para isso.

Substituições têm permissão para designar um membro required onde não era required no tipo base. Um membro com essa marcação é adicionado à lista de membros obrigatórios do tipo derivado.

Os tipos têm permissão para substituir as propriedades virtuais obrigatórias. Isso significa que, se a propriedade virtual base tiver armazenamento e o tipo derivado tentar acessar a implementação base dessa propriedade, eles poderão observar o armazenamento não inicializado. NB: Este é um antipadrão C# geral, e não achamos que essa proposta deve tentar resolvê-la.

Efeito na análise de nulidade

Os membros marcados required não precisam ser inicializados para um estado nulo válido no final de um construtor. Todos os membros required deste tipo e de qualquer tipo base são considerados pela análise de nulabilidade como padrão no início de qualquer construtor neste tipo, a menos que haja um encadeamento para um construtor this ou base que tenha o atributo SetsRequiredMembersAttribute.

A análise de nulidade alertará sobre todos os membros required dos tipos atuais e de base que não possuam um estado de nulidade válido ao final de um construtor com o atributo SetsRequiredMembersAttribute.

#nullable enable
public class Base
{
    public required string Prop1 { get; set; }

    public Base() {}

    [SetsRequiredMembers]
    public Base(int unused) { Prop1 = ""; }
}
public class Derived : Base
{
    public required string Prop2 { get; set; }

    [SetsRequiredMembers]
    public Derived() : base()
    {
    } // Warning: Prop1 and Prop2 are possibly null.

    [SetsRequiredMembers]
    public Derived(int unused) : base()
    {
        Prop1.ToString(); // Warning: possibly null dereference
        Prop2.ToString(); // Warning: possibly null dereference
    }

    [SetsRequiredMembers]
    public Derived(int unused, int unused2) : this()
    {
        Prop1.ToString(); // Ok
        Prop2.ToString(); // Ok
    }

    [SetsRequiredMembers]
    public Derived(int unused1, int unused2, int unused3) : base(unused1)
    {
        Prop1.ToString(); // Ok
        Prop2.ToString(); // Warning: possibly null dereference
    }
}

Representação de metadados

Os 2 atributos a seguir são conhecidos pelo compilador do C# e são necessários para que esse recurso funcione:

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
    public sealed class RequiredMemberAttribute : Attribute
    {
        public RequiredMemberAttribute() {}
    }
}

namespace System.Diagnostics.CodeAnalysis
{
    [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
    public sealed class SetsRequiredMembersAttribute : Attribute
    {
        public SetsRequiredMembersAttribute() {}
    }
}

É um erro aplicar manualmente RequiredMemberAttribute a um tipo.

Qualquer membro marcado como required tem um RequiredMemberAttribute aplicado a ele. Além disso, qualquer tipo que define esses membros é marcado com RequiredMemberAttribute, como um marcador para indicar que há membros necessários nesse tipo. Observe que, se o tipo B derivar de A, e A definir membros required, mas B não adicionar novos membros ou substituir nenhum membro required existente, B não será marcado com um RequiredMemberAttribute. Para determinar completamente se há membros necessários no B, é necessário verificar a hierarquia de herança completa.

Qualquer construtor em um tipo com membros required que não têm SetsRequiredMembersAttribute aplicado é marcado com dois atributos:

  1. System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute com o nome do recurso "RequiredMembers".
  2. System.ObsoleteAttribute com a cadeia de caracteres "Types with required members are not supported in this version of your compiler", e o atributo é marcado como um erro, para impedir que os compiladores mais antigos usem esses construtores.

Não usamos um modreq aqui porque é uma meta manter a compatibilidade binária: se a última propriedade required tiver sido removida de um tipo, o compilador não sintetizaria este modreq, o que é uma quebra binária e todos os consumidores precisarão ser recompilados. Um compilador que entenda os membros required ignorará esse atributo obsoleto. Observe que os membros também podem vir de tipos base: mesmo que não haja novos membros required no tipo atual, se qualquer tipo base tiver membros required, esse atributo Obsolete será gerado. Se o construtor já tiver um atributo Obsolete, nenhum atributo de Obsolete adicional será gerado.

Usamos ObsoleteAttribute e CompilerFeatureRequiredAttribute porque este último é novo nesta versão e os compiladores mais antigos não o entendem. No futuro, poderemos descartar o ObsoleteAttribute e/ou não usá-lo para proteger novos recursos, mas por enquanto precisamos de ambos para proteção total.

Para criar a lista completa de membros required R para um determinado tipo T, incluindo todos os tipos base, o seguinte algoritmo é executado:

  1. Para cada Tb, começando com T e percorrendo a cadeia de tipos base até que object seja atingido.
  2. Se Tb estiver marcado com RequiredMemberAttribute, todos os membros de Tb marcados com RequiredMemberAttribute serão reunidos em Rb
    1. Para cada Ri em Rb, se Ri for substituído por qualquer membro do R, ele será ignorado.
    2. Caso contrário, se algum Ri estiver oculto por um membro do R, a pesquisa de membros necessários falhará e nenhuma outra ação será tomada. Chamar qualquer construtor de T sem atributo de SetsRequiredMembers gera um erro.
    3. Caso contrário, Ri será adicionado ao R.

Perguntas abertas

Inicializadores de membro aninhados

Quais serão os mecanismos de aplicação para inicializadores de membro aninhados? Eles serão totalmente proibidos?

class Range
{
    public required Location Start { get; init; }
    public required Location End { get; init; }
}

class Location
{
    public required int Column { get; init; }
    public required int Line { get; init; }
}

_ = new Range { Start = { Column = 0, Line = 0 }, End = { Column = 1, Line = 0 } } // Would this be allowed if Location is a struct type?
_ = new Range { Start = new Location { Column = 0, Line = 0 }, End = new Location { Column = 1, Line = 0 } } // Or would this form be necessary instead?

Perguntas discutidas

Nível de aplicação para cláusulas init

O recurso de cláusula init não foi implementado no C# 11. Ela continua sendo uma proposta ativa.

Exigimos estritamente que os membros especificados em uma cláusula init que não possuem inicializador tenham todos os membros inicializados? Parece provável que sim, caso contrário, criamos uma armadilha fácil para o fracasso. No entanto, também corremos o risco de reintroduzir os mesmos problemas que resolvemos com MemberNotNull no C# 9. Se quisermos impor isso estritamente, provavelmente precisaremos de uma maneira para um método auxiliar indicar que ele define um membro. Algumas sintaxes possíveis que discutimos para isso:

  • Permita os métodos init. Esses métodos só podem ser chamados de um construtor ou de outro método init e podem acessar this como se estivesse no construtor (ou seja, definir readonly e init campos/propriedades). Isso pode ser combinado com cláusulas do tipo init nesses métodos. Uma cláusula init seria considerada satisfeita se o membro na cláusula estivesse definitivamente atribuído no corpo do método/construtor. Chamar um método com uma cláusula init que inclui um membro é considerado como atribuir a esse membro. Se viermos a decidir que esta é uma rota que queremos seguir, seja agora ou no futuro, provavelmente não devemos usar init como a palavra-chave para a cláusula init em um construtor, pois isso seria confuso.
  • Permitir que o operador ! suprima o aviso/erro explicitamente. Se inicializar um membro de forma complicada (como em um método compartilhado), o usuário poderá adicionar um ! à cláusula init para indicar que o compilador não deve verificar se há inicialização.

Conclusão: após a discussão, gostamos da ideia do operador !. Ele permite que o usuário seja intencional sobre cenários mais complicados, ao mesmo tempo em que não cria um grande buraco de design em torno de métodos init e anota todos os métodos como configuração de membros X ou Y. ! foi escolhido porque já o usamos para suprimir avisos anuláveis e usá-lo para dizer ao compilador "Sou mais inteligente do que você" em outro lugar é uma extensão natural da forma de sintaxe.

Membros da interface necessários

Esta proposta não permite que as interfaces marquem os membros conforme necessário. Isso nos protege de ter que descobrir cenários complexos em torno de new() e restrições de interface em genéricos neste momento, e está diretamente relacionado a fábricas e construção genérica. Para garantir que tenhamos espaço de design nessa área, proibimos required em interfaces e proibimos tipos com required_member_lists de serem substituídos por parâmetros de tipo restritos a new(). Quando queremos dar uma olhada mais ampla em cenários genéricos de construção com fábricas, podemos rever essa questão.

Perguntas de sintaxe

O recurso de cláusula init não foi implementado no C# 11. Ela continua sendo uma proposta ativa.

  • É init a palavra certa? init como modificador de sufixo no construtor poderá interferir se quisermos reutilizá-lo para fábricas e também habilitar métodos init com um modificador de prefixo. Outras possibilidades:
    • set
  • O modificador required está correto para especificar que todos os membros são inicializados? Outros sugeriram:
    • default
    • all
    • Com um ! para indicar lógica complexa
  • Devemos exigir um separador entre o base/this e o init?
    • Separador :
    • Separador ","
  • É required o modificador certo? Outras alternativas que foram sugeridas:
    • req
    • require
    • mustinit
    • must
    • explicit

Conclusão: removemos a cláusula do construtor init por enquanto e continuamos com required como modificador de propriedade.

Restrições da cláusula Init

O recurso de cláusula init não foi implementado no C# 11. Ela continua sendo uma proposta ativa.

Devemos permitir o acesso a this na cláusula init? Se quisermos que a atribuição em init seja uma abreviação para atribuir o membro no próprio construtor, parece ser uma boa ideia fazermos isso.

Além disso, ele cria um novo escopo, como base(), ou compartilha o mesmo escopo que o corpo do método? Isso é particularmente importante para funções locais, que a cláusula init pode querer acessar, ou para o sombramento de nomes, se uma expressão de inicialização introduzir uma variável por meio do parâmetro out.

Conclusão: a cláusula init foi removida.

Requisitos de acessibilidade e init

O recurso de cláusula init não foi implementado no C# 11. Ela continua sendo uma proposta ativa.

Nas versões desta proposta com a cláusula init, falamos sobre como ter o seguinte cenário:

public class Base
{
    protected required int _field;

    protected Base() {} // Contract required that _field is set
}
public class Derived : Base
{
    public Derived() : init(_field = 1) // Contract is fulfilled and _field is removed from the required members list
    {
    }
}

No entanto, removemos a cláusula init da proposta neste momento, portanto, precisamos decidir se desejamos permitir esse cenário de forma limitada. As opções que temos são:

  1. Não permita o cenário. Esta é a abordagem mais conservadora, e as regras na Acessibilidade estão atualmente escritas com essa suposição em mente. A regra é que qualquer membro obrigatório deve ser, no mínimo, tão visível quanto seu tipo que o contém.
  2. Exija que todos os construtores sejam:
    1. Não mais visível do que o membro menos visível necessário.
    2. Tenha o SetsRequiredMembersAttribute aplicado ao construtor. Isso garantiria que qualquer pessoa que tenha acesso a um construtor possa definir todos os elementos que ele exporta, ou então que não haja nada a definir. Isso pode ser útil para tipos que só são criados por meio de métodos Create estáticos ou construtores semelhantes, mas o utilitário parece globalmente limitado.
  3. Leia uma maneira de remover partes específicas do contrato para a proposta, conforme discutido no LDM anteriormente.

Conclusão: Opção 1, todos os membros obrigatórios devem estar pelo menos tão visíveis quanto o tipo que os contém.

Regras de substituição

A especificação atual diz que a palavra-chave required precisa ser copiada e que as substituições podem tornar um membro mais obrigatório, mas não menos. É isso que queremos fazer? Permitir a remoção de requisitos precisa de mais habilidades de modificação de contrato do que estamos propondo no momento.

Conclusão: a adição de required na substituição é permitida. Se o membro substituído for required, o membro substituído também deverá ser required.

Representação de metadados alternativos

Também poderíamos adotar uma abordagem diferente para a representação de metadados, inspirando-se nos métodos de extensão. Poderíamos colocar um RequiredMemberAttribute no tipo para indicar que o tipo contém membros necessários e, em seguida, colocar um RequiredMemberAttribute em cada membro necessário. Isso simplificaria a sequência de pesquisa (não é necessário fazer pesquisa de membro, basta procurar membros com o atributo).

Conclusão: Alternativa aprovada.

Representação de metadados

A Representação de Metadados precisa ser aprovada. Além disso, precisamos decidir se esses atributos devem ser incluídos no BCL.

  1. Para RequiredMemberAttribute, este atributo se assemelha mais aos atributos gerais incorporados que usamos para nomes de membros anuláveis/nint/tupla e não será aplicado manualmente pelo usuário em C#. No entanto, é possível que outros idiomas queiram aplicar esse atributo manualmente.
  2. SetsRequiredMembersAttribute, por outro lado, é usado diretamente pelos consumidores e, portanto, provavelmente deve estar no BCL.

Se formos com a representação alternativa na seção anterior, isso poderá alterar o cálculo em RequiredMemberAttribute: em vez de ser semelhante aos atributos gerais inseridos para nomes de membros nint/anulável/tupla, ele está mais próximo de System.Runtime.CompilerServices.ExtensionAttribute, que está na estrutura desde que os métodos de extensão foram enviados.

Conclusão: colocaremos os dois atributos no BCL.

Aviso vs Erro

A ausência de configuração de um membro obrigatório deve resultar em um aviso ou um erro? É certamente possível enganar o sistema, por meio de Activator.CreateInstance(typeof(C)) ou semelhante, o que significa que talvez não possamos garantir totalmente que todas as propriedades estejam sempre definidas. Também permitimos a supressão dos diagnósticos no local do construtor usando o !, o que geralmente não permitimos para erros. No entanto, o recurso é semelhante a campos somente leitura ou propriedades de inicialização, pois ocorre um erro rígido se os usuários tentarem definir esse membro após a inicialização, mas eles podem ser contornados usando reflexão.

Conclusão: erros.