链式空值检查和 Maybe Monad






4.94/5 (142投票s)
展示了几个扩展方法如何解决“重复的空值检查”问题。
引言
很多程序员都遇到过这样的情况:在访问嵌套对象属性(例如:person.Address.PostCode
)时,需要进行多次空值检查。在 XML 解析中,当缺失的元素和属性在尝试访问时返回 null
(随后尝试访问 Value
会引发 NullReferenceException
)时,这种需求经常出现。在本文中,我将展示如何在 C# 中通过对 Maybe Monad 的一种实现,结合使用扩展方法,来提高代码的可读性。
问题描述
那么,首先,让我们看看如何获取一个人的邮政编码(就想象你在处理 XML 或其他东西)。下面展示的代码进行了多次空值检查,并且仅在值可用时才进行赋值。
string postCode = null;
if (person != null && person.Address != null &&
person.Address.PostCode != null)
{
postCode = person.Address.PostCode.ToString();
}
你上面看到的代码相当难读(且难以维护)。实际上,我们很幸运地将所有代码都包含在一个 if
语句中——在更复杂的情况下,这可能是不可能的。让我们想象一个更复杂的情况——比如说,我们需要在 if
条件评估之间执行某些操作。我们会得到什么?没错——一连串的 if
语句。
string postCode;
if (person != null)
{
if (HasMedicalRecord(person) && person.Address != null)
{
CheckAddress(person.Address);
if (person.Address.PostCode != null)
postCode = person.Address.PostCode.ToString();
else
postCode = "UNKNOWN";
}
}
上面提供的代码包含了很多冗余信息——例如,person.Address.PostCode
被提到了两次。代码本身并没有什么错误,只是符号有点太多了。总而言之,我们希望我们的代码能更好地传达以下信息:
- 如果值为
null
,则不应进行进一步的评估;如果值为非null
,则这是我们将要处理的值。 - 如果我们执行某个操作,那么它只会在有效对象上发生。
那么我建议什么呢?我建议我们创建一个流畅的接口,它能在没有任何嵌套的情况下满足上述条件。为此,我们将采用Maybe Monad。
对于熟悉 F# 的人来说,Maybe Monad 会以 Option 类型的形式出现。对于 C# 开发者来说,我们只需要假定一个变量可能有一个值,或者没有值(空)。当然,C# 不直接支持这种“有值-无值”的二元性,除非使用 null
。这正是我提出下面链式扩展解决方案的原因。
有了
我们的首要任务是简化空值检查,让它们不至于污染我们的代码。为此,我们将定义一个名为 With()
的扩展方法。
public static TResult With<TInput, TResult>(this TInput o,
Func<TInput, TResult> evaluator)
where TResult : class where TInput : class
{
if (o == null) return null;
return evaluator(o);
}
上述方法可以附加到任何类型(因为 TInput
实际上是 object
)。作为参数,此方法接受一个函数,该函数定义了“链中的下一个值”。如果我们传入 null
,我们将返回 null
。让我们使用此方法重写第一个示例。
string postCode = this.With(x => person)
.With(x => x.Address)
.With(x => x.PostCode);
我想,在上面的示例中,我们可以用 Expression<>
替换 Func<>
并尝试获取属性,但我见过这样做,结果代码速度太慢,而且也有一定的局限性——它假定你只处理一个对象,而我的 Maybe 链可以(也确实)引入多个对象。
返回
接下来是另一个语法糖——Return()
方法。此方法将返回“当前”值,就像 Where()
一样,但如果传入的是 null
,它将返回我们提供的不同值。你可以将其视为一种“带回退的 Where()
”方法。
public static TResult Return<TInput,TResult>(this TInput o,
Func<TInput, TResult> evaluator, TResult failureValue) where TInput: class
{
if (o == null) return failureValue;
return evaluator(o);
}
所以,现在我们假设,在没有邮政编码的情况下,我们想返回,比如 string.Empty
。方法如下:
string postCode = this.With(x => person).With(x => x.Address)
.Return(x => x.PostCode, string.Empty);
顺便说一句,你可以重写扩展方法,使 failureValue
也通过 Func<>
计算——尽管我还没有遇到需要这种做法的场景。通常情况下,我们永远不知道链在哪个阶段失败(并返回 null
),因此终端的 Return()
通常是一个指示器(可能是 true/false
或 null
/非 null
)。
If & Unless
在调用链中,有时你需要进行与 null
无关的检查。理论上,你可以暂停链并使用 if
,或者在某个委托中使用 if
,但是……你可以简单地定义一个 If()
扩展方法(如果你愿意,也可以定义一个 Unless()
)并将其插入链中。
public static TInput If<TInput>(this TInput o, Func<TInput, bool> evaluator)
where TInput : class
{
if (o == null) return null;
return evaluator(o) ? o : null;
}
public static TInput Unless<TInput>(this TInput o, Func<TInput, bool> evaluator)
where TInput : class
{
if (o == null) return null;
return evaluator(o) ? null : o;
}
Do
既然我们在这里开派对,那就再添加一个简单调用委托的方法——仅此而已。当然,这个方法最适合单行调用,而不是评估包含复杂逻辑的 20 行算法。尽管如此,这种调用在实践中非常有用。
public static TInput Do<TInput>(this TInput o, Action<TInput> action)
where TInput: class
{
if (o == null) return null;
action(o);
return o;
}
好了,我们完成了:我们有了使邮政编码提取更具可读性所需的基础架构。这是最终结果。
string postCode = this.With(x => person)
.If(x => HasMedicalRecord(x))
.With(x => x.Address)
.Do(x => CheckAddress(x))
.With(x => x.PostCode)
.Return(x => x.ToString(), "UNKNOWN");
如你所见,嵌套深度降至零——没有更多的花括号了!
讨论
我在我的 R2P 软件产品中使用了这些 Maybe-monadic-chain-null-extension-methods(你可以随意称呼它们)。这里有一个这些结构在实际使用中的例子。
public override void VisitInvocationExpression(IInvocationExpression expression)
{
base.VisitInvocationExpression(expression);
string typeName = this.With(x => expression)
.With(x => x.InvokedExpression)
.With(x => x as IReferenceExpression)
.With(x => x.Reference)
.With(x => x.Resolve())
.With(x => x.DeclaredElement)
.With(x => x.GetContainingType())
.Return(x => x.CLRName, null);
this.If(x => Array.IndexOf(types, typeName) != -1)
.With(x => ExpressionStatementNavigator.GetByExpression(expression))
.Do(x =>
{
var suggestion = new SideEffectSuggestion(typeName);
var highlightInfo = new HighlightingInfo(
expression.GetDocumentRange(),
suggestion);
context.HighlightingInfos.Add(highlightInfo);
});
}
我需要指出的是,在任何时候,你都可以停止链并开始一个新的链。你为什么要这样做?嗯,例如,你不能在链内定义共享变量(除非你将其重构为具有类似 Dictionary<string,object>
的参数)。
顺便说一句,我经常发现自己创建额外的、特定于域的方法来插入这个链中。例如:
public static IElement IsWithin<TContainingType>(this IElement self)
where TContainingType: class, IElement
{
if (self == null) return self;
var owner = self.GetContainingElement<TContainingType>(false);
return owner == null ? self : null;
}
还有一件事:这种类型的表示法实际上是一种轻微的混淆,因为,我敢肯定你已经猜到了,每个扩展方法的调用在 Reflector 中都会显示为静态方法调用。
public override void VisitInvocationExpression(IInvocationExpression expression)
{
base.VisitInvocationExpression(expression);
string typeName = this.With<SideEffectAnalyser, IInvocationExpression>(
delegate (SideEffectAnalyser x) {
return expression;
}).With<IInvocationExpression, ICSharpExpression>(delegate (IInvocationExpression x) {
return x.InvokedExpression;
}).With<ICSharpExpression, IReferenceExpression>(delegate (ICSharpExpression x) {
return (x as IReferenceExpression);
}).With<IReferenceExpression, IReference>(delegate (IReferenceExpression x) {
return x.Reference;
})
.
.
.
// and so on
}
这种方法很容易扩展——例如,我的一个同事也在他的链中进行了 try-catch
检查。嘿,这有点像 AOP,但没有后构建或动态代理。哦,与 if
语句相比,这些链的性能损失微不足道。
就是这样!评论,一如既往,欢迎!
更新 1:我收到了关于如何在此层次结构中传播值类型的提问。这很简单:你所要做的就是创建一个另一个链方法,它不执行空值检查。
public static TResult WithValue<TInput, TResult>(this TInput o,
Func<TInput, TResult> evaluator)
where TInput:struct
{
return evaluator(o);
}