【问题标题】:C# non-nullable field: Lateinit?C# 不可为空的字段:Lateinit?
【发布时间】:2020-03-23 11:22:04
【问题描述】:

我想知道如何在 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 作为参数传递给每个方法,但请记住,我有一些这样的字段,这违反了我眼中的干净代码原则。

我正在寻找一种方法来告诉编译器,我将在使用之前初始化doc(实际上我这样做了,调用者不可能得到空引用异常!)。我认为 Kotlin 有 lateinit 修饰符正是为了这个目的。

你会如何在“干净”的 C# 代码中解决这个问题?

【问题讨论】:

  • 您尝试初始化它吗?像这样:private PdfDoc doc = null;
  • 类似问题,只是从构造函数移到声明:“无法将空字面量转换为不可为空的引用类型。”
  • 那么可能是某种private PdfDoc doc = PdfDoc.Empty;,其中PdfDoc.Emptystatic readonly 字段?
  • 不,不是。在我看来,null 与“未初始化”不同。浏览我的代码,在初始化之前不可能读取pdfDoc 变量。我没有违反了非空规则,编译器似乎无法证明这一点(还)。
  • 使用反射可以破坏各种东西,甚至将null 值放入不可为空的类型中。代码分析只能处理常规的控制流,这很好。

标签: c# nullable-reference-types lateinit


【解决方案1】:

目前我发现的最佳解决方案是这个:

private PdfDoc doc = null!;

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

【讨论】:

  • 这告诉编译器“让它为空,我理解所有后果,我不在乎”(不是那么“干净”的方法)
  • 是的。但仅在此声明时。之后我无法再次将其设置为null(这很好)。所以这似乎真的是 Kotlin 的“lateinit”的“等价物”。
  • @Andi 我认为你应该接受这个作为答案。感谢您巧妙地使用了 null-forgiving 运算符。刚才当我需要类似 lateinit 的解决方案时,它帮助了我。
  • 我也同意@vasily.sib 的观点,即这不是“干净的”,就像 Kotlin 中的 lateinit 不干净一样。在 Kotlin 中,我总是尽量避免使用 lateinit,到目前为止我已经成功了。但是如果你确实需要像 lateinit 这样的东西,这不是一个糟糕的解决方案,因为代码量是如此之少。如果您在使用它之前未能初始化该成员,那么您很可能在第一次尝试访问它时获得 NullReferenceException(尽管我认为不能保证),这足以说明它还没有被初始化。跨度>
  • 我也喜欢 Sean 的解决方案,因为代码在意图方面更加明确和可读。请在他们的答案下查看我的评论,以了解如何在编译时对其进行调整以使其更清洁、更安全。无论选择哪种解决方案,以某种方式将其与类似于其他答案之一中讨论的构建器模式的东西结合起来可能是一个好主意。理想情况下,PdfCreator 构造函数是私有的,公共静态构建器/工厂方法只会在初始化所有属性后返回一个构造实例。
【解决方案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 相比,它仍然感觉像是不必要的样板代码。
  • 我会使用[NotNull] attribute 来表示返回值永远不会为空。
  • 好吧,使用 C# 8 的新 nullable 编译器功能应该已经过时了。
  • @Zer0 - 如果您使用新的可空引用类型,那么它隐式不为空。
  • 嗯?链接的文档适用于 C# 8 和可为空的引用类型。也许我应该更清楚。如果意图是 GetDocument 永远不会返回 null 我会用 [NotNull] 装饰它
【解决方案3】:

听起来您想要的是一种在您的方法中添加可空性前提条件的方法(即,如果我在字段 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();
   }

【讨论】:

    【解决方案4】:

    您的代码看起来像是构建者模式,read more about it

        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 的解决方案的需求,我认为将此解决方案与其他解决方案之一结合通常是最佳选择。 public static Builder() 函数可以在返回之前完成 PdfBuilder 的初始化。
    【解决方案5】:

    它可能不能直接解决 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 字段被初始化为非空值。

    【讨论】:

      猜你喜欢
      • 2018-05-12
      • 2022-01-24
      • 2021-03-22
      • 2023-02-18
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2015-03-16
      • 2020-01-09
      相关资源
      最近更新 更多