3.4 使用 Option 对可能缺少数据的情况进行建模

  Option类型被用来表示没有数据的可能性,在C#和其他许多编程语言(以及数据库)中,通常用null来表示。 我希望向你展示Option对可能不存在的数据给出了一个更稳健和富有表现力的表示。

3.4.1 你每天使用的糟糕 API

  在框架库中,表示可能没有数据的问题并没有得到很好的处理。想象一下,你去参加一个工作面试,得到了以下的测验:
问题:这个程序打印什么?

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using static System.Console;
class IndexerIdiosyncracy {
    public static void Main() {
        try {
            var empty = new NameValueCollection();
            var green = empty["green"];
            WriteLine("green!");
            var alsoEmpty = new Dictionary < string, string > ();
            var blue = alsoEmpty["blue"];
            WriteLine("blue!");
        } catch (Exception ex) {
            WriteLine(ex.GetType().Name);
        }
    }
}

TIP NameValueCollection是一个从字符串到字符串的映射。 例如,当你调用ConfigurationManager.AppSettings来获取一个.config文件的设置时,你得到一个NameValueCollection。

  花点时间读一读代码。然后,写下你认为程序打印的内容(确保没有人看到)。一旦你回答了这个问题,你愿意用多少钱来打赌你得到了正确的答案?如果你像我一样,有一种耿耿于怀的感觉,即作为一个程序员,你真的应该关心其他的事情,而不是这些恼人的细节,本节的其余部分将帮助你看到为什么问题在于API本身,而不是你缺乏知识。
  这段代码使用索引器从两个空的集合中检索项目,所以两个操作都会失败。 当然,索引器只是普通的函数–[]语法只是sugar,所以这两个索引器都是string -> string类型的函数,而且都是不诚实的。
  如果一个键不存在,NameValueCollection索引器返回null。null是否真的是一个字符串还有待商榷,但我倾向于说不是。你给索引器一个完全有效的输入字符串,它就会返回无用的null值–而不是签名中所说的。
  Dictionary indexer 抛出了一个KeyNotFoundException,所以它是一个说 "给我一个字符串,我会返回给你一个字符串 "的函数,而实际上它应该说 “给我一个字符串,我可能返回给你一个字符串,或者我可能抛出一个异常。”
  雪上加霜的是,这两个索引器的不诚实程度是不一致的。知道了这一点,我们很容易看到程序打印的内容如下:

green!
KeyNotFoundException

  也就是说,.NET中两个不同的关联集合所暴露的接口是不一致的。谁会想到呢?而发现的唯一方法就是查看文档(无聊)或偶然发现一个错误(更糟糕)。
  让我们看看表示可能缺少数据的函数方法。

3.4.2 Option 类型介绍

  Option 本质上是一个包装一个值……或没有值的容器。它就像一个盒子,里面可能装着东西,也可能是空的。 Option 的符号定义如下:

Option< T > = None | Some(T)

  让我们看看这意味着什么。T是一个类型参数,即内部值的类型,所以一个Option< int >可以包含一个int,也可以不包含。符号的意思是 “或”,所以定义中说一个Option< T >可以是两种东西中的一种,或者说,它可以处于两种 "状态 "中的一种:

  • None —— 一个特殊的值,表示没有一个值。 如果期权没有任何价值,我们就说 “该Option为None”。
  • Some(T) —— 一个包装T类型的值的容器。如果Option有一个内在价值,我们说 “该Option是Some”。

Option IS ALSO CALLED Maybe 不同的功能框架使用不同的术语来表达相似的概念。 Option 的常见同义词是Maybe,Some 和None 状态分别称为Just 和Nothing。
  不幸的是,这种命名不一致的情况在FP中相当普遍,这对学习过程没有帮助。在本书中,我将尝试为每个模式或技术介绍最常见的同义词,然后坚持使用一个名称。
  所以从现在开始,我将坚持使用Option;你只需知道,如果你遇到Maybe比如,在JavaScript或Haskell库中–它是同样的概念。

  我们将在下一小节中讨论实现Option的问题,但首先让我们看一下它的基本用法,以便你熟悉这个API。我建议你在REPL中跟随;你需要进行一些设置,这在 "在REPL中使用LaYumba.Functional库 "侧边栏中有所描述。

在 REPL 中使用 LaYumba.Functional 库
在 REPL 中使用 LaYumba.Functional 库中的构造需要一些设置:

  1. 如果您还没有这样做,请从 https://github.com/la-yumba/functional-csharp-code 下载并编译代码示例。
  2. 在你的REPL中引用LaYumba.Functional库。具体如何操作取决于你的设置。在我的系统中(使用Visual Studio中的REPL,打开代码样本解决方案),我可以通过输入以下内容来实现:

#r “functional-csharp-code\LaYumba.Functional\bin\Debug
➥ netstandard1.6\LaYumba.Functional.dll”

  1. 在 REPL 中键入以下导入:
    using LaYumba.Functional;
    usingstatic LaYumba.Functional.F;

设置完成后,您可以创建一些Options:

//在 None 状态下创建一个 Option
Option<string> _ = None;
//在 Some 状态下创建一个 Option
Option<string> john = Some("John");

  这很容易! 现在你知道了如何创建Option,那么你如何与它们互动呢?在最基本的层面上,你可以用Match这个方法来进行交互,这个方法可以进行顺序匹配。简单地说,它允许你根据Option是无还是有来运行不同的代码。
  例如,如果你有一个可选择的名字,你可以写一个函数来返回对该名字的问候,如果没有给出名字,则返回一个通用的消息。 在 REPL 中键入以下内容:

string greet(Option<string> greetee)   
	=> greetee.Match(  
	None: () => "Sorry, who?",    //如果greetee 是None,Match 将评估这个函数。
	Some: (name) => $"Hello, {name}");   //如果greetee 是Some,Match 将评估这个函数,传递它greetee 的内部值。
greet(None) // => "Sorry, who?"
greet(Some("John")) // => "Hello, John"

  正如你所看到的,Match 有两个函数:第一个函数说的是在 None 的情况下要做什么,第二个是在 Some 的情况下要做什么。 在Some情况下,该函数将给出Option的内部值(在本例中,是字符串 “John”,是创建Option时给出的值)。
  在前面对Match的调用中,命名的参数None:和Some:是为了更清晰地表达。也可以省略这些:

string greet(Option<string> greetee)
	=> greetee.Match(
		() => "Sorry, who?",
		(name) => $"Hello, {name}");

  一般来说,我将省略它们,因为第一个 lambda 中的空括号()已经暗示了一个空的容器(也就是一个处于 None 状态的 Option),而里面有一个参数的括号(name)则暗示了一个里面有一个值的容器。
  如果这一切现在有点混乱,不要担心;随着时间的推移,事情会逐渐归位。现在,这些是需要记住的事情:

  • 使用 Some(value) 将值包装到 Option 中。
  • 使用 None 创建一个空的Option。
  • 使用 Match 根据 Option 的状态运行一些代码。

  现在,你可以认为None是null的替代品,而Match是null检查的替代品。从概念上讲,前面的代码与此没有什么不同:

string greet(string name)
	=> (name == null)
		? "Sorry, who?" 
		: $"Hello, {name}";

  在后面的章节中你会看到为什么使用 Option 比 null 更加可取,以及为什么最终你不需要经常使用 Match。不过,首先让我们来看看引擎盖下面的内容。

3.4.3 实施 Option

  您可以在第一次阅读时跳过本节,或者如果您只对理解足以能够使用 Option 感兴趣。在这里,我将向您展示我在实现 LaYumba.Functional 中包含的Option时使用的技术。 这既是为了告诉你,这里面没有什么神奇的东西,也是为了展示绕过C#类型系统的一些限制的可能方法。
  在许多类型化的函数式语言中, Option 可以按照以下方式使用单行定义:

type Option t = None | Some t

  在 C# 中,需要做更多的工作。首先,您需要 None 和 Some< T > 来表示Option的每个可能状态。

清单 3.5 实现 Some 和 None 类型

namespace LaYumba.Functional {
    public static partial class F {
    	//The None value
        public static Option.None None => Option.None.Default; 
        // Some 函数将给定的值包装成 Some
        public static Option.Some < T > Some < T > (T value) => new Option.Some < T > (value);
    }
    namespace Option {
        public struct None {//None 没有成员,因为它不包含任何数据。
            internal static readonly None Default = new None();
        }
        public struct Some < T > {
            internal T Value { //Some只是包装一个值
                get;
            }
            internal Some(T value) { // Some代表数据的存在,所以不允许出现空值
                if (value == null) throw new ArgumentNullException();
                Value = value;
            }
        }
    }
}

  F类是作为客户端代码的入口;它暴露了值None,即None 选项,以及函数Some,它将一个给定的T包装成一个Some< T >。
  None 表示没有值,所以它是一种没有实例字段的类型。就像 Unit 一样,None 只有一个可能的值。有些只有一个字段来保存内部值;这不能为空。
  前面的代码允许您在 None 或 Some 状态下显式创建值:

usingstatic LaYumba.Functional.F;
var firstName = Some("Enrico");
var middleName = None;

  下一步是定义更一般的Option< T >类型,它可以是None或Some< T >。就集合而言,Option< T >是集合Some< T >与单子集合None的联合(见图3.4)。
在这里插入图片描述
  事实证明这并不容易,因为 C# 没有语言支持来定义这样的“联合类型”。理想情况下,我希望能够写出这样的东西。

清单 3.6 Option 与其情况 None 和 Some 的理想化关系

namespace LaYumba.Functional {
    interface Option < out T > {}
    namespace Option {
        public struct None: Option < T > { /* ... */ }
        public struct Some < T >: Option < T > { /* ... */ }
    }
}

  也就是说,我想说 None 是一个 Option< T >,Some< T > 也是。不幸的是,前面的代码有几个问题(因此无法编译):

  • None 没有(也不需要)一个类型参数T;因此它不能实现Option< T >的通用接口。如果None可以被视为一个Option< T >而不管类型参数T最终被赋予什么类型就好了,但是C#的类型系统并不支持这样做。
  • Option< T > 只能是以下两种情况之一:None 或 Some< T >。任何客户端程序集都不应该定义 Option< T > 的任何其他实现,但是没有语言功能来强制执行此操作。

  考虑到这些问题,为 Option 使用一个接口或抽象类并不能很好地工作。 相反,我把Option< T >定义为一个单独的类,并定义了一些方法,这样None和Some< T >都可以隐含地转换为Option< T >(如果你愿意的话,通过隐含转换来继承)。

清单 3.7 Option< T >可以同时捕获Some和None状态。

public struct Option < T > {
    readonly bool isSome;//捕获 Option 的状态:true if the Option is Some
    readonly T value;//Option的内在值
    private Option(T value) {
        this.isSome = true;
        this.value = value;
    }
    //将 None 转换为 Option
    public static implicit operator Option < T > (Option.None _) => new Option < T > ();
    //将 Some 转换为 Option
    public static implicit operator Option < T > (Option.Some < T > some) => new Option < T > (some.Value);
    //将常规值“提升”为 Option
    public static implicit operator Option < T > (T value) => value == null ? None : Some(value);
    //Match 采用两个函数,并根据 Option 的状态评估其中一个。
    public R Match < R > (Func < R > None, Func < T, R > Some) => isSome ? Some(value) : None();
}

  Option的这个实现可以表示None和Some;它有一个Boolean 值来区分这两种状态,还有一个T类型的字段来存储Some的inner值。
  现在你可以把None当作任何类型T的一个Option< T >。当None被转换为一个Option< T >时,isSome标志将是假的;内部值将是T的默认值并被忽略。 当Some< T >被转换为一个Option< T >时,isSome标志为真,内部值被保存。
  我还添加了一个方法来隐含地将一个T类型的值提升为一个Option< T >,这在某些情况下会很方便。 如果值是空的,它将产生一个处于无状态的 Option,否则它将把值包装成一个 Some。
  最重要的部分是 Match,它允许你根据 Option 的状态来运行代码。匹配是一种方法,它说:“告诉我在没有值的时候你想做什么,在有值的时候你想做什么,我会做任何合适的事情。”
  有了这些,你就可以消费一个 Option。 再看一下我前面展示的Match的使用。现在应该更清楚了:

string greet(Option<string> greetee)   
	=> greetee.Match(
		None: () => "Sorry, who?",
		Some: (name) => $"Hello, {name}");
greet(None) // => "Sorry, who?"
greet(Some("John")) // => "Hello, John"

  请注意,在C#中还有许多其他可能的方法来定义一个选项。我选择了这个特殊的实现,因为从客户代码的角度来看,它允许最简洁的 API。但是 Option 是一个概念,而不是一个特定的实现,所以如果你在其他库或教程中看到一个不同的实现,也不要惊慌:

  • 一个值 None 表示没有值
  • 一个函数 Some 包装了一个值,表示一个值的存在
  • 在一个值是否存在的情况下有条件地执行代码的方法(在我们的例子中,是Match)。

  接下来,让我们看看为什么使用 Option 比使用 null 来表示可能不存在的值更好。

3.4.4 通过使用Option而不是null来获得稳健性

  我在前面提到,应该用None代替null,用Match代替一个null的检查。让我们通过一个实际的例子来看看这样做有什么好处。
  想象一下,你的网站上有一个表单,允许人们订阅新闻邮件。一个订阅者输入他们的名字和电子邮件,这将导致创建一个Subscriber实例,定义如下,它被持久化到数据库中:

public class Subscriber{
	public string Name { get; set; }
	public string Email { get; set; }
}

  当发送新闻简报的时候,为订阅者计算出一个自定义的问候语,它将被预置在新闻简报的正文中:

public string GreetingFor(Subscriber subscriber)
	=> $"Dear {subscriber.Name.ToUpper()},";

  这一切都运行良好。名字不能为空,因为它是注册表的一个必填字段,而且在数据库中不能为空。
  几个月后,新用户的注册率下降了,因此该公司决定不再要求新用户输入姓名,以降低进入市场的门槛。姓名字段被从表格中删除,数据库也做了相应的修改。
  这应该被认为是一个突破性的变化,因为不可能再对数据做同样的假设了。如果你允许Name为空,代码将很容易编译,而当GreetingFor收到一个没有Name的订阅者时,它将抛出一个异常。
  这时,负责在数据库中制作可选名字的人可能与维护发送新闻邮件的代码的人在不同的团队。这些代码可能在不同的仓库中。简而言之,查找Name的所有用途可能并不简单。
  相反,最好明确指出Name现在是可选的。订阅者类应该被修改成这样:

public class Subscriber {
    public Option < string > Name { get; set; } //名字现在被明确地标记为可选的
    public string Email { get; set; }
}

  这不仅清楚地表达了Name的值可能不存在的事实,而且导致GreetingFor不再编译。GreetingFor,以及其他任何访问Name属性的代码,都必须进行修改,以考虑到值不存在的可能性。例如,你可以这样修改它:

public string GreetingFor(Subscriber subscriber)
	=> subscriber.Name.Match(
	      () => "Dear Subscriber,",      
	      (name) => $"Dear {name.ToUpper()},");

  通过使用Option,你迫使你的API的用户处理节点数据可用的情况。 这对客户端的代码提出了更高的要求,但是它有效地消除了发生NullReferenceException的可能性。将一个字符串改为Option< string >是一个突破性的改变:通过这种方式,你将运行时的错误换成了编译时的错误,从而使编译后的应用程序更加健壮。

3.4.5 Option作为部分函数的自然结果类型

  我们已经讨论了函数如何将元素从一个集合映射到另一个集合,以及类型化编程语言中的类型如何描述这些集合。在全部函数和部分函数之间有一个重要的区别:

  • 完全函数(Total functions)是为域的每个元素定义的映射。
  • 局部函数(Partial functions)是对域中的某些元素,但不是所有元素定义的映射。

  局部函数是有问题的,因为不清楚当给定一个不能计算结果的输入时,函数应该怎么做?Option类型为这种情况提供了一个完美的解决方案:如果函数对给定的输入进行了定义,那么它就会返回一个Some包住的结果;否则就返回None。
  让我们看一些可以使用这种方法的常见用例。

解析字符串
  想象一下,有一个函数可以解析一个整数的字符串表示法。你可以将其建模为一个string -> int类型的函数。这显然是一个局部函数,因为不是所有的字符串都是整数的有效表示。 事实上,有无限多的字符串不能被映射成一个int。
  你可以通过让解析器函数返回一个 Option来提供一个更安全的解析方式。 如图 3.5 所示,如果给定的字符串不能被解析,这个值将是 None。
在这里插入图片描述
  一个签名为string -> int的解析器函数是部分的,从签名中不清楚如果你提供一个不能被转换为int的字符串会发生什么。 另一方面,一个签名为string -> Option< int >的解析器函数是完全的,因为对于任何给定的字符串,它将返回一个有效的Option< int >。
  这是一个使用框架方法来完成繁重工作但公开一个基于 Option 的 API 的实现:

public static class Int {
    public static Option < int > Parse(string s) {
        int result;
        return int.TryParse(s, out result) ? Some(result) : None;
    }
}

  本小节中的辅助函数包含在LaYumba.Functional中,所以你可以在REPL中试用它们:

Int.Parse("10")    // => Some(10)
Int.Parse("hello") // => None

  类似的方法被定义为将字符串解析为其他常用的类型,如数字和日期,以及更普遍的,将一种形式的数据转换成另一种限制性的形式。

查找数据集
  在本节的开头部分,我向你展示了框架集合暴露了一个既不诚实也不一致的表示没有数据的API。要点如下:

new NameValueCollection()["green"]
// => null
new Dictionary<string, string>()["blue"]
// => runtime error: KeyNotFoundException

  基本问题如下。一个关联集合将键映射到值,因此可以被看作是一个TKey -> TValue类型的函数。 但是,不能保证集合中每个可能的TKey类型的键都包含一个值,所以查找一个值总是一个局部函数。
  一个更好、更明确的方法是通过返回一个Option来模拟一个值的检索。我们可以写一些适配器函数来暴露一个基于Option的API,并且通常将这些返回Option的函数命名为Lookup:

Lookup : (NameValueCollection, string) -> Option< string >

  Lookup接收一个NameValueCollection和一个字符串(键),如果键存在,将返回Somewith值,否则返回None。这里是实现:

public static Option<string> Lookup
	(this NameValueCollection @this, string key)   
	=> @this[key];

  这就是了! 表达式@this[key]是字符串类型的,而返回值是Option< string >,所以字符串值将被隐式转换为Option< string >。 (记住,在前面显示的Option的实现中,从一个值T到一个Option的隐式转换被定义为如果该值为null则返回None,否则将该值提升为一个Some)。) 我们已经从一个基于null的API变成了一个基于Option的API。
  这里有一个 Lookup 的重载,它接受一个 IDictionary。签名类似:Lookup : (IDictionary< K, T>, K) -> Option< T > Lookup 函数可以如下实现。

public static Option < T > Lookup < K, T > (this IDictionary < K, T > dict, K key) {
    T value;
    return dict.TryGetValue(key, outvalue) ? Some(value) : None;
}

  我们现在有一个诚实、清晰、一致的API来查询这两个集合。当你用Lookup访问这些集合时,编译器强迫你处理None的情况,而且你清楚地知道会发生什么:

new NameValueCollection().Lookup("green")
// => None
new Dictionary<string, string>().Lookup("blue")
// => None

  再也不会因为你要求的键在集合中不存在而出现KeyNotFoundException或NullReferenceException。在查询其他数据结构时也可以采用同样的方法。

智能构造器模式
  在本章的前面,我们定义了Age类型,一个比int更严格的类型,只允许表示一个人的年龄的有效值。 当从一个int创建一个Age时,我们需要考虑到给定的int不代表一个有效的年龄的可能性。你可以再次用 Option 来建立模型,如图 3.6 所示。
在这里插入图片描述
  如果你需要从一个int创建一个Age,而不是调用构造函数(如果它不能创建一个有效的实例,必须抛出一个异常),你可以定义一个返回Some或None的函数,以表示成功创建一个Age。 这被称为智能构造函数:它的 "智能 "之处在于它知道一些规则,可以防止构造出一个无效的对象。

清单 3.8 为 Age 实现智能构造函数

public struct Age {
  private int Value { get; }
  //返回 Option 的智能构造函数
  public static Option < Age > Of(int age) => IsValid(age) ? Some(new Age(age)) : None;
  //构造函数可以标记为私有
  private Age(intvalue) {
    if (!IsValid(value)) 
    	throw new ArgumentException($"{value} is not a valid age");
    Value = value;
  }
  private static bool IsValid(int age) => 0 <= age && age < 120;
}

  如果你现在需要从一个int中获取一个Age,你会得到一个Option< Age >,这迫使你考虑到失败的情况。如果你的Option< Age >是None,你会怎么处理它? 嗯,这取决于上下文和要求。 在接下来的章节中,我们将探讨如何有效地使用Option。 尽管Match是与Option交互的基本方式,但我们将在下一章开始建立一个丰富的、高级的API。
  综上所述,当表示一个可选值时,Option应该是你的默认选择。在你的数据对象中使用它来模拟一个属性可能不会被设置的事实,在你的函数中使用它来表示一个合适的值可能不会被返回。除了减少出现NullReferenceException的机会外,这还会丰富你的模型,使你的代码更具有自我记录性。

防范NullReferenceException 为了进一步防止代码中潜伏的NullReferenceExceptions,永远不要写一个明确返回null的函数,并且永远检查你的API中公共方法的输入是否为null。

  在你的函数签名中使用Option是你实现本章首要建议的一种方式:设计出诚实的、高度描述可以从函数中得到什么的函数签名。我试图说明这如何通过减少运行时错误的机会使你的应用程序更加健壮,但没有什么比实验证明更重要的了,所以在你自己的代码中试试这些想法吧。
  在下一章中,我们将充实Option API。Option将是你的朋友,不仅在你的程序中使用它,而且作为一个简单的结构,我将通过它来说明许多FP概念。

总结
  • 使您的函数签名尽可能具体。这将使它们更易于使用且不易出错。
  • 让你的函数诚实。一个诚实的函数总是做它的签名,给定一个预期类型的输入,它产生一个预期类型的输出–没有异常,没有Null。
  • 使用自定义类型而不是特别的验证代码来约束一个函数的输入值,并使用智能构造函数来实例化这些类型。
  • 使用 Option 类型来表示可能没有值。 Option 可以处于以下两种状态之一:
    • None,表示没有值
    • Some,一个简单的容器包装一个非空值
  • 要根据选项的状态有条件地执行代码,可以在无和有的情况下使用Match与你想评估的函数。
Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐