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

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


领域驱动开发

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

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

识别糟糕的设计

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

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

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

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

可见这个方法的接口/签名并没有清晰到可以直接回答上述任何一个问题。如果某个方法的签名或接口只定义调用者和它本身之间的直白协议,那会相当糟糕。当然有很多约定的使用惯例让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#版本的实现方式非常高效而且几乎没有噪音。作为一名码农,我不必编写大量的样板代码,还能够专注于我真正想要的实际领域:

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

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

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

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


译者注:划重点!

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,752评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,100评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,244评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,099评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,210评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,307评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,346评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,133评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,546评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,849评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,019评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,702评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,331评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,030评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,260评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,871评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,898评论 2 351