为什么说你该学学F#?(一)

翻译:顾远山
版权归作者所有,转载请标明出处。
原文链接:Why you should learn F#
原文作者:Dustin Moris Gorski
原贴较长,遂翻译时将其拆分成若干篇并组成序列,此篇为系列之一。


领域驱动开发

写这篇帖子之前,我问自己到底是什么原因让你这么喜欢F#?脑海里涌现出很多理由,其中最突出的一个,是F#具有强大的领域建模能力。毕竟我们作为码农,主要工作就是把现实世界抽象成数字模型。要是哪门编程语言可以让我们的工作变得自然,无疑它会非常有价值,必不应被错过。

为了进一步说明我的观点,让我们来看些代码作为例子。在此任务及此篇中其余的任务里,我将用F#和C#的代码进行对比,以便给大家展现F#带来的好处。这里我选C#作对比,是因为很多码农认为它是最好的面向对象编程语言之一,当然更主要的原因是——C#也是我最熟练的编程语言。

识别糟糕的设计

现代应用程序中有个常见的用例,就是从数据库中读取客户对象。在C#中它看起来长这样:

public Customer GetCustomerById(string customerId)
{
    // do stuff...
}

我故意省略了此方法的内部逻辑,因为从调用者的角度看,方法的签名通常便是我们所知的全部。尽管例子中的操作如此简单(而且熟悉),但它仍存在很多未知点:

这个customerID参数可以接受哪些值?空字符串行吗?很可能不行,但它会立即抛出一个ArgumentException异常吗?还是照旧尝试获取数据?
这个customerID参数的值遵循某种特定的格式吗?如果customerID格式正确但里面的字母全部是大写怎么办?它对大小写敏感吗?还是这个方法会统一对字符串做标准化?
如果给定customerID的值在数据库里不存在呢?它会返回空值呢,还是抛出异常呢?在不检查方法内部实现(文档、反编译、Github等)的情况下,我们没有办法发现这些问题的答案,除非把所有类型的输入逐条进行测试。
如果数据库连接不通,它会返回和“客户不存在”相同的结果吗?亦或抛出另一种不同的异常?
这段代码到底能抛出多少种不同的异常?

可见这个方法的接口/签名并没有清晰到可以直接回答上述任何一个问题。如果某个方法的签名或接口只定义调用者和它本身之间的直白协议,那会相当糟糕。当然有很多约定的使用惯例让C#开发者感觉安全,主要通过对底层代码的广泛假设,但这些假设将(且终将)引发严重的错误。某个库要是和早前建立的惯例稍有不同,那它很可能引入后期才能捕获到的缺陷。

甚至可能使用惯例对于缺失的语言特性来说是相对弱的解决办法。就好比C#可能被认为是比JavaScript更好的语言,因其自带静态类型特性;而很多函数式编程语言被认为优于C#、Java和别的语言,因其自带领域建模特性。虽然在C#中有方法去改善例子的代码,但并没有一种是简单直接的(或者说通常都非常繁琐),这就是为什么广大码农依然在编写着大量类似(糟糕设计)代码的原因。

F#让正确的代码变的简单

反之,F#有着丰富的类型系统,它容许码农尽情表达函数的真实状态。倘若某函数可能会或可能不会返回Customer对象,它都可以返回一个选项类型Option<'T>的结果。选项类型Option<'T>定义了某个返回值可以有值或无值。

let getCustomerById customerId =
    match db.TryFindCustomerById customerId with
    | true, customer -> Some customer
    | false, _ -> None

理解下面这点很重要:None并不是null的另一种说法,因为null实际上是什么都没有(不分配内存),而None是选项类型Option<'T>的一个实际对象/一种情况。

例子中的TryFindCustomerId方法就是典型的.NET成员,它定义了out参数如下:

bool TryFindCustomerById(string customerId, out Customer customer)

成功找到Customer对象后,在F#中你可以使用简单的模式匹配来抽取out参数的值:

match db.TryFindCustomerById customerId with
| true, customer -> Some customer
| false, _ -> None

选项类型Option<'T>的好处是它不仅能更好表达(而且更诚实地反映函数的真实状态),它还迫使调用代码为None(无值的情况)提供实现,也就意味着码农不得不从一开始就得考虑这种边界情况:

let someOtherFunction customerId =
    match getCustomerById customerId with
    | Some customer -> // Do something when customer exist
    | None -> // Do something when customer doesn't exist

F#自带的另一种极其有用的类型是结果类型Result<'T, 'TError>

let validateCustomerId customerId =
    match customerId with
    | null -> Error "Customer ID cannot be null."
    | ""  -> Error "Customer ID cannot be empty."
    | id when id.Length <> 10 -> Error "Invalid Customer ID."
    | _ -> Ok (customerId.ToLower())

这里的validateCustomerId函数,要么返回带着某个标准化customerIdOk对象,要么返回包含相关错误信息的Error对象。例子中的'T'TError都是字符串类型,但它们可以不必相同,你甚至可以把多个类型包装进某个更丰富的返回值类型里,比如Result<Option<Customer>, string list>

F#中的类型系统其实还有更强大的灵活性。码农容易创建某种新类型非常容易,如下面的代码通过自定义的DataResult<'T>类型,忠实反映getCustomerById的函数所有可能的结果:

type DataResult<'T> =
    | Success        of 'T option
    | ValidationError of string
    | DataError      of Exception

let getCustomerById customerId =
    try
        match validateCustomerId customerId with
        | Error msg -> ValidationError msg
        | Ok    id  ->
            match db.TryFindCustomerById id with
            | true, customer -> Some customer
            | false, _      -> None
            |> Success
    with ex -> DataError ex

DataResult<'T> 类型声明了调用代码想要区别对待的三种不同情况。通过显示声明某种代表所有可能性的类型,我们可以对getCustomerById 函数进行建模,这种方法消除了全部错误和边界处理的模糊性,同时还防止了意外行为并迫使调用它的代码处理这些情况。

F#让无效的状态变成不可能

到目前为止我们一直假设customerId是字符串类型,但通过上述对比,我们发现它的实际值能产生很多模糊性,迫使C#的码农写出一大堆东西防止犯错:

public Customer GetCustomerById(string customerId)
{
    if (customerId == null)
        throw new ArgumentNullException(nameof(customerId));
    if (customerId == "")
        throw new ArgumentException(
            "Customer ID cannot be empty.", nameof(customerId));
    if (customerId.Length != 10 || customerId.ToLower().StartsWith("c"))
        throw new ArgumentException(
            "Invalid customer ID", nameof(customerId));
    // do stuff...
}

避免这种反模式正确的方式是把CustomerId的概念抽象建模成为独有的类型。在C#中你可以通过创建一个类或结构体来实现,但无论你用哪种方法,到最后你都得写一堆样板代码才能让这个类型如你所愿般可用(比如GetHashCodeEqualityToString等方法):

public class CustomerId
{
    public string Value { get; }
    public CustomerId(string customerId)
    {
        if (customerId == null)
            throw new ArgumentNullException(nameof(customerId));
        if (customerId == "")
            throw new ArgumentException(
                "Customer ID cannot be empty.",
                nameof(customerId));
        var value = customerId.ToLower();
        if (value.Length != 10 || value.StartsWith("c"))
            throw new ArgumentException(
                "Invalid customer ID",
                nameof(customerId));
        Value = value;
    }
    // Lots of overrides to make a
    // CustomerId behave the correct way
}

毋庸置疑,对于每个自定义类型都这么来一遍会非常烦人,所以在C#的实践中比较少见(译者注:在规范的企业C#项目中,带有领域业务含义的自定义类型并不少见,其中有些项目专门把领域专用的自定义类型按业务抽象Model层,但无论如何代码冗长是真的)。另外,新建一个类也不太可取,因为接受CustomerId的代码仍然需要处理null(空值,实际没有东西)的可能性。一个CustomerId永远不该为null,正如一个int类型的值、一个Guid类型的值、或一个DateTime类型的值永远不能是空值。 等你用C#实现了这个正确的CustomerId类型,你发现已经写了200行代码,而且它们没准还有可能引发更多错误。

而用F#我们很容易就能定义一个新类型,如下:

type CustomerId = private CustomerId of string

CustomerId类型基本上是string类型的封装,但也提供了额外的类型安全,因为不会有人出乎意料地把string赋值于CustomerId类型的参数,反之亦然。而私有访问器则防止来自其他模块或命名空间的代码创建CustomerId类型的对象。这是刻意而为,这么做意在强制代码只能通过特定的函数来创建对象,如下:

module CustomerId =
    let create (customerId : string) =
        match customerId with
        | null -> Error "Customer ID cannot be null."
        | ""  -> Error "Customer ID cannot be empty."
        | id when id.Length <> 10 -> Error "Invalid Customer ID."
        | _ -> Ok (CustomerId (customerId.ToLower()))

以上F#版本的实现方式非常高效而且几乎没有噪音。作为一名码农,我不必编写大量的样板代码,还能够专注于我真正想要的实际领域:

系统有一个名叫CustomerId的类型,它封装了一个string类型。
想要创建一个CustomerId类型的对象,唯一途径是通过CustomerId.create函数,而且它在发出CustomerId对象之前已经做完了所有相关的检查。
如果某个string违反了CustomerId类型的要求,函数会返回一个有具体含义的Error,而调用函数的代码必须处理这个错误场景。
CustomerId类型的对象不可变且不可为空。对象一旦被成功创建,所有后续的代码都可以大胆地依赖其正确的状态。
CustomerId类型自动从string类型继承其他所有行为,这意味着我不必为其单独实现GetHashCode方法、重写相等判断、重载运算符,以及其他所有在C#中我不得不做的无谓之举。

这是个很好的例子能说明F#以极少的代码提供巨大的价值,加上由于代码量少,犯错的几率也非常小了。唯一真正我可能会犯错的地方,在实际对CustomerId做校验的实现上,这种错误更多是领域相关的职责,而非F#这门编程语言本身的不足。

使用C#的码农们不太习惯把诸如CustomerId, OrderIdEmailAddress等真实世界的概念建模出来他们自己的类型,因为通过C#这门语言做这种任务并不轻松。而这些原本属于某个领域类型的对象通常被好比string或者int之类非常简单的类型所表示,再松散地按领域处理。

如果你想学习更多关于用F#进行领域驱动设计的知识,我会强烈推荐你去看看斯科特W在NDC London中做的演讲《函数式实现领域建模》,那是一场极好的演讲,里面有很多值得思考的东西,也正是我此篇帖子一些想法的来源。


译者注:划重点!

原文作者认为F#之所以值得推荐很大程度上因为其出色的领域建模能力。领域建模的基石便是自定义类型。通过F#和C#在实现自定义类型的对比,为大家展现了F#的优势,其中包括简洁的代码、清晰的逻辑、灵活的类型系统等。
具体实现例子的过程涉及到F#特有的功能,比如选项类型Option<'T>,结果类型Result<'T, 'TError>。
当然作为函数式编程语言,不可变性和模式匹配等也是和诸如C#等主要面向对象的编程语言有较大区别的地方,但二者分别有助于降低代码出错概率和减少代码噪音。

猜你喜欢

转载自blog.csdn.net/weixin_34007020/article/details/87207280