C#非空字段:Lateinit?

9
我希望您能够翻译以下内容:

我想知道如何在使用可空引用类型的C#中使用late-initialized类字段。 想象一下以下类:

public class PdfCreator { 

   private PdfDoc doc;

   public void Create(FileInfo outputFile) {
       doc = new PdfWriter(outputFile);
       Start();
   }

   public void Create(MemoryStream stream) {
       doc = new PdfWriter(stream);
       Start();
   }

   private void Start() {
      Method1();
      // ...
      MethodN();
   }

   private void Method1() {
      // Work with doc
   }

   // ...

   private void MethodN() {
      // Work with doc
   }
}

上面的代码非常简化。我的实际类使用了更多像doc这样的字段,并且还有一些带有一些参数的构造函数。
使用上述代码,我在构造函数上得到了编译器警告,提示doc未初始化,这是正确的。我可以通过将doc的类型设置为PdfDoc?来解决这个问题,但是那样我就必须在使用它的地方使用?.!.,这很麻烦。
我也可以将doc作为参数传递给每个方法,但请记住我有一些像这样的字段,这违反了我的“clean code”原则。
我正在寻找一种方法,告诉编译器,在使用它之前我将初始化doc(实际上我已经这样做了,调用者不可能得到空引用异常!)。我认为Kotlin正是出于这个目的而有lateinit修饰符。
你会如何用“clean” C#代码解决这个问题?

1
你尝试过初始化它吗?像这样:private PdfDoc doc = null; - vasily.sib
这有点“hacky”,确实如同你试图让nullable reference type假装成non-nullable reference type一样“hacky”。 - vasily.sib
1
不,它不是。在我看来,'null'并不等同于“未初始化”。在我的代码中,无法在初始化pdfDoc变量之前读取它。我并没有违反非空规则,编译器似乎只是无法证明这一点(但还没有)。 - Andi
2
使用反射,您可以破坏各种事情,甚至将“null”值放入非空类型中。代码分析只能处理常规控制流,这很好。 - Andi
不,如果在 Method1 中进行了空值检查(可空引用类型),或者在构造函数中初始化了 doc(非空引用类型)。 - vasily.sib
显示剩余6条评论
5个回答

14

到目前为止,我找到的最佳解决方案是这个:

private PdfDoc doc = null!;

通过使用C# 8中引入的null-forgiving operator,可以消除所有编译器警告。它允许您像非空值一样使用值。因此,在需要类似于Kotlin的“lateinit”的东西时,可以使用其中一种方式。与Kotlin的lateinit不同的是,这里实际上会将其初始化为空,这将被编译器和运行时都允许。如果稍后在不期望为null的位置使用此变量,则可能会出现NullReferenceException,并且编译器不会警告您它可能为null,因为它认为它不为null。 Kotlin的lateinit有一个细微的差别,即如果在初始化之前访问lateinit属性,则会抛出一个特殊的异常,清楚地标识正在访问的属性以及尚未初始化的事实。


3
这告诉编译器:“让它为空,我了解所有后果,并且我并不关心”(不是很“干净”的方法)。 - vasily.sib
1
正确。但仅在此声明时才能设置它为null。此后我将无法再将其设置为 null(这很好)。因此,这似乎真的是Kotlin“lateinit”的“等效物”。 - Andi
@Andi 我认为你应该接受这个答案。感谢您巧妙地使用了空值容错运算符。当我需要一个类似于lateinit的解决方案时,它帮助了我。 - still_dreaming_1
我也同意@vasily.sib的观点,即这种方法并不像Kotlin中的lateinit那样“干净”。在Kotlin中,我总是尽量避免使用lateinit,并且到目前为止我已经成功了。但是如果你确实需要类似于lateinit的东西,这并不是一个坏的解决方案,因为代码量非常小。如果在使用成员之前未对其进行初始化,则很可能在第一次尝试访问它时会出现NullReferenceException(虽然我认为不能保证),这足以表明它尚未初始化。 - still_dreaming_1
我也喜欢Sean的解决方案,因为代码更明确、更易读。请看我的评论,在他们的答案下有一种方法可以将其调整得更加清晰和在编译时更安全。无论选择哪种解决方案,最好能与其他答案中讨论的建造者模式类似的东西结合起来。理想情况下,PdfCreator构造函数应该是私有的,一个公共的静态builder/工厂方法只会在初始化所有属性后返回构造的实例。 - still_dreaming_1
请注意,Kotlin 中的 lateinit 关键字专门用于处理强制使用“不规范”模式的第三方框架。有时,框架会强制你在 Start 或 Init 方法中初始化事物,而不是在构造函数中。类型系统无法了解框架,但由于框架保证调用这些方法,因此这是安全的,比使用可空性要好得多。 - Alexander Weickmann

2

晚期初始化对可空引用类型可能会有些棘手

一种选择是将成员变量设为可空,并添加一个函数来包装访问:

private PdfDocument? pdfDocument = null;

private PdfDocument GetDocument()
{
  if(pdfDocument == null) throw new InvalidOperationException("not initialized");

  return pdfDocument;
}

请注意,编译器不会对此方法发出警告,因为它认识到该方法只有在pdfDocument非空时才会返回。
有了这个设置,现在您可以将您的方法更改为以下内容:
private void Method1() 
{
  var doc = GetDocument();

  // doc is never null
}

现在你的代码更准确地模拟了意图。即使只是短暂的时间,pdfDocument 可能为 null,而你的方法可以访问文档,知道它们永远不会返回 null


为了泛化这个问题,我可以将此检查移动到全局方法 T assertNotNull(T? obj) 并调用:var pdfDoc = assertNotNull(this.pdfDoc)。但与 Kotlin 的简单 lateinit 相比,仍然感觉像是不必要的样板代码。 - Andi
1
好的,使用C# 8的新“可空”编译器功能,这应该已经过时了。 - Andi
@Zer0 - 如果你正在使用新的可空引用类型,那么它隐式地不为null。 - Sean
1
如果您将其更改为带有后备字段的属性,则会更加简洁。getter 将与此 GetDocument() 方法相同,但优点是 setter 将在编译时防止其被设置为 null。 - still_dreaming_1
@Matt - 我并不认为将所有内容都塞进一个表达式体成员中总是有助于可读性。 - Sean
显示剩余4条评论

1

你的代码看起来像是建造者模式,了解更多

    public class PdfBuilder
    {
        private PdfDoc _doc;

        private PdfBuilder(PdfDoc doc)
        {
            _doc = doc;
        }

        public static PdfBuilder Builder(FileInfo outputFile)
        {
            var writer = new PdfWriter(outputFile);
            return new PdfBuilder(writer.ReadPdfDoc());
        }

        public void Build() 
        {
            Stage1();
            StageN();
        }

        private void Stage1() 
        {
            // Work with doc
        }

        // ...

        private void StageN() 
        {
            // Work with doc
        }
    }

我同意,看起来Andi无意中正在寻找一种类似于建造者模式的解决方案,或者某种工厂函数。如果可以使用这个来避免需要暂时未初始化属性的需要,那就更好了。即使这并没有消除lateinit解决方案的需要,我认为将此解决方案与其他解决方案结合起来通常是最好的选择。公共静态Builder()函数可以在返回之前完成PdfBuilder的初始化。 - still_dreaming_1

1
听起来你想要的是一种在方法上添加空值前提条件的方式(即,如果我在字段 X、Y 或 Z 可能为空的情况下调用此实例方法,请发出警告)。目前该语言不支持此功能。您可以在https://github.com/dotnet/csharplang 提出语言特性请求。

根据您的类型初始化方式,可能会有不同的可行替代方案。看起来你有以下阶段:

  1. 使用一些参数调用构造函数,并将参数保存到字段中。
  2. 调用 Create() 的重载版本,并填充“后期初始化”的字段。
  3. Create() 调用 Start(),几乎完成所有其他操作。

在这种情况下,我会考虑将使用后期初始化字段的方法提取到另一个类型中:

public class PdfCreator {

    public void Create(FileInfo outputFile) {
        var context = new PdfCreatorContext(new PdfWriter(outputFile));
        context.Start();
    }

    public void Create(MemoryStream stream) {
        var context = new PdfCreatorContext(new PdfWriter(stream));
        context.Start();
    }

    private struct PdfCreatorContext
    {
        private PdfDoc doc;
        internal PdfCreatorContext(PdfDoc doc)
        {
            this.doc = doc;
        }

        internal void Start() {
            Method1();
            // ...
            MethodN();
        }

        internal void Method1() {
            // Work with doc
            doc.ToString();
        }

        // ...

        internal void MethodN() {
            // Work with doc
        }
    }
}

可能这个类的使用比这更复杂,或者像异步和变异这样的问题使得使用 struct 不切实际。在这种情况下,你至少可以要求你的方法在编译器允许它们使用可空字段之前检查自己的先决条件:

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;

public class PdfCreator { 
   PdfDoc? doc;

   [Conditional("DEBUG"), MemberNotNull(nameof(doc))]
   private void AssertInitialized()
   {
      Debug.Assert(doc != null);
      // since the real thing has many nullable fields, we check them all
      // in here, and reference them all in the MemberNotNull attribute.
   }

   private void Method1() {
      AssertInitialized();
      // Work with doc with the assumption it is not-null.
      // In the case that any method is called with an unexpected
      // null field in debug builds, we crash as early as possible.
      doc.ToString();
   }

   private void Method2() {
      // oops! we didn't AssertInitialized, so we get a warning.
      doc.ToString(); 
   }
}

请注意,[MemberNotNull] 目前仅在 .NET 5 预览版中可用。在 .NET Core 3 中,您可以编写 Debug.Assert,在调用点检查所有需要的可空字段。
   private void Method1() {
      Debug.Assert(doc != null);
      doc.ToString();
   }

0

它可能不能直接解决 OP 的问题,但因为搜索“late init”把我带到了这里,所以我要发一篇帖子。

虽然你可以使用其他答案中解释的 null! 技巧,如果你通过一些辅助方法而不是直接在构造函数中初始化非空类成员变量,那么有一种更优雅的方式来声明它。你需要在你的辅助方法上使用 MemberNotNull(nameof(Member)) 属性。

public class TestClass
{
    private string name;

    public TestClass()
    {
        Initialize();
    }

    [MemberNotNull(nameof(name))]
    private void Initialize()
    {
        name = "Initialized";
    }
}

这样,编译器就不会再抱怨在退出构造函数后非空的name没有被设置,因为它知道调用Initialize确保了name字段被初始化为非空值。


如果 Initialize() 是虚拟函数并被子类重写,而且在子类的 Initialize() 中设置,我发现它不起作用。 - Raid

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接