【函数式编程】【C#/F#】第五讲 函数式编程中的错误处理,副作用管理

在上一讲中,我们学习了在函数式编程中一个,非常重要的概念。藉由这个武器,我们将来挑战我们最开始做讲述的概念,代码的副作用。当然,如果你还记得的话,程序的异常也是副作用的其中一种。

复习时间:会影响外界状态(状态突变)、使用到了IO(输入输出)的功能、改变输入参数,或是抛出异常叫做副作用

如果你觉得有部分知识点已经忘了,可以回顾一下之前的内容

【C#/F#】「函数式编程」序章 - 不可变与表达式两大戒律https://blog.youkuaiyun.com/scixing/article/details/144569395?spm=1001.2014.3001.5502

在本期开始之前需要安装LanguageExt库(nuget)

并引入以下两行

using LanguageExt;
using static LanguageExt.Prelude;

单子在FP之中有一个重要的任务,那就是管控副作用的范围。这也是我们一直想要强调的,副作用在程序中不可避免。但我们不应该让副作用在程序中到处都是,而是要牢牢地把控住其能影响的范围。副作用不可或缺,但其存在具有两面性。而单子的存在,能最大限度的减少其不好的一面。

之前我们已经了解到了Option这个单子,今天我们还将遇见一个新的单子

我们先来想象一下option是否真的够用

Either<L,R>

这是我们新的单子!也有称之为Result(Result<Ok, Error>(F#就是如此)),不过Result在LanguageExt中有另外一个意义,这里我们还是用Either作为介绍。

Either内部有两类值,一般我们称之为LeftRight

当然按照字面意义,Left会写在左面,而Right在英文中也有正确的意思,所以Either代表的是一个成功的值Right或者一个失败的信息Left。

相比于Option,Either能够传达更多更详细的信息,所以在实际使用时可能会见到更多的使用Either在代码中进行流转。(因为option只能表达数据的缺失这一种状态)

事不宜迟,我们先来写一下Either的Bind和Map的函数签名...

图片

欸等等? Either有两个有效数据我们应该怎么写?

确实是这样,不过通常情况下我们只需要关心Right的数据即可

bind:  (Either<L, R>, (R -> Either<L, R1>)) -> Either<L, R1>
map:  (Either<L, R>, (R -> R1)) -> Either<L, R1>

以下是Map与Bind的实际实现(当然 安装了库的我们不需要手动自己实现)

public static Either<L, R1> Map<L, R, R1>(this Either<L, R> either,
        Func<R, R1> f) =>
        either.Match<Either<L, R1>>(
            Right: r => Right(f(r)),
            l => Left(l)
        );
    public static Either<L, R1> Bind<L,R,R1>(this Either<L, R> either,
        Func<R, Either<L, R1>> f) =>
        either.Match<Either<L, R1>>(
            Right: r => f(r),
            l => Left(l)
        );

同样的非常简单,当Either中值为Left的时候,bind,map都不会再影响到Either的值,当然如果你想对其中的Left值进行操作,试着实现一下MapLeft与BindLeft!

现在我们得到了一种保存错误情况下信息的方式,我们还有必要在代码中抛出错误吗?throw会肆意的中断我们的程序,这其实在大部分时候往往不是我们所想要的。试着思考一下,当我们去问一个问题的时候,我们希望得到什么样的回复。

如果能得到正确的回复当然最好不过,如果无法解决的时候,你希望获得的是

1. 一个什么都没有的回复(Option.None)

2. 一个告诉你为什么问题不能回答的原因(Either.Left(reason))

3. 不回复,然后直接打了一拳,但你可以在医院里知道打你造成的结果(throw)

你会选择什么呢?相信答案显而易见,当然可能对throw的描述略显夸张,但抛出错误的语义可能会跨越非常大的代码范围,如果没有处理,也可能直接使程序崩溃。这不是一件好事,我们需要控制他的范围。

实战

现在来看一种情况,你是否会遇到过这种类似这种每一步都需要尝试校验,最终才能得到结果的代码

bool DoSth1(int id)
{
    var equip = Equipment.Get(id);
    if (equip == null)
    {
        throw new Exception("No equipment found");
    }
    var open = equip.Open();
    if (!open)
    {
        throw new Exception("open failed");
        return false;
    }
    var dosth = equip.DoSth();
    if (!dosth)
    {
        throw new Exception("dosth failed");
        return false;
    }
    var close = equip.Close();
    if (!close)
    {
        throw new Exception("close failed");
        return false;
    }
    return true;
}

难以想象的复杂,开发者最后有可能最后为了缩减代码,将计就计,让错误直接在方法内部调用的时候就直接抛出。这是一种方法,但是毫无疑问,这会造成全局乱成一锅粥,随便调用一个接口都要承担程序直接崩溃的风险!

而如果你使用了Either, 并聪明的使用了Bind去连接函数操作,你会发现你得到了相当简洁的一个代码,并且不会有任何崩溃的风险

Either<string, EquipmentF> DoSth(int id) =>
    id.Get()
        .Bind(EquipmentExtensions.Open)
        .Bind(EquipmentExtensions.Pre)
        .Bind(EquipmentExtensions.DoSth)
        .Bind(EquipmentExtensions.Close);

当我们拿到结果后,我们会得到一个可能的结果,这个结果可能是Right, 那么我们将拿到正确的值。也可能是Left,我们会拿到最早导致错误的信息。当然既然错误了,错误之后的步骤自然没有执行。

以下便是FP中代码运行的流程

图片

(也有将形如这种的编程模式称之为面向铁路编程)

可以看到,新的方法让我们回避了多次无聊的判断,并且让程序的出口也只变成了唯一一个,同时如果我们希望的话,也可以直接使用=>来返回最终的结果。我们成功利用了单子,将错误牢牢地管控在了一个很小的范围中,不必担心突如其来的程序无法处理的错误。

try..catch

即使圣人不偏不倚的走在了正确的道路上,也依然难以避免这样或者那样的错误。

没错事实上很多异常我们没有办法避免,我们还是需要Try来帮助我们捕获一些异常。

不过在C#之中,try是一个语句。这使得try的语句连贯性不高,我想编写过类似代码的同学一定有类似的体验

var t = SomeMethod(); // 这步可能出错
DoSth(t);

如果SomeMethod是一个可能出错的代码,并且我们也没有办法对SomeMethod本身进行重构。我们可能需要try来帮我们捕获代码的错误。我们可能不得不这么修改

SType t = null; // 这步可能出错
try
{
    t = SomeMethod();
}
catch (Exception e)
{
    throw;
}
DoSth(t);

太难看了!

我们不仅要捏着鼻子将一个null赋值,最终有可能还是会抛出错误。代码整体也变得特别割裂,我相信大家都不想经常写这么难看的代码,这也是我们排斥语句的原因之一。

那么根据我们学到的内容来说,我们更期待一种怎么样的写法呢?

我们想到了刚刚所说的Either,在这种情况下,try中有可能有一个有效的值,或是一个错误。根据定义我们可以得出一个类型Either<Exception, R>, 他的左值总是一个错误类型,这可以是一个Either的特化类型,Exceptional<T>, (在LanguageExt中,它叫做Result<T>),现在有了我们想要的结果的类型,接下来要做的,则是规避掉这个疯狂的语法,转换为语句

我们当然想要这么写

var t = 
    try
    {
        return SomeMethod();
    }
    catch (Exception e)
    {
        return e;
    }
DoSth(t);

这种写法,清晰,明确,且没有复杂的赋值顺序,这在F#有类似的实现,但在C#之中,我们没有类似的语法帮我们实现这一点。所以我们需要自己创建一个函数帮我们解决这一切。

其实我们需要的只是一段代码的封装, 接下来写出我们所需要的函数签名

Run<T>:  (()->T) -> Exceptional<T>

我们将这个()->T声明为Try<T>, 并编写出Run/Invoke函数

public delegate Result<T> Try<T>();
    public static Result<T> Run<T>(this Try<T> f)
    {
        try
        {
            return f();
        }
        catch (Exception e)
        {
            return new(e);
        }
    }

我们简单的Try就完成了,在LanguageExt中,Try也提供Bind和Map函数,所以同样也是一个单子,有兴趣的同学,也可以尝试实现这一个工作

在F#中,我们直接支持try作为表达式使用,非常方便。

验证器

如果仔细观察Exception, 可以注意到,Exception都是一些意料之外的错误,其实在代码中我们还可能遇到一些错误,这些错误不是致命性的,但是可能有悖业务逻辑,例如我们需要判定某人年龄是否大于18岁,或是是否是教师。这些意料之内的不合理,我们更常使用验证器来对齐收集,这是另一个版本的Etiher,我们通常用它来收集数据/逻辑不合理的等情况,

这时候我们可能会使用另一个Either变体 Validation,

Either<IEnumerable<Error>,T>

篇幅问题在本片就不继续衍生了,在之后的实战演练中,我们会更深刻的理解这部分内容。

Bilibili:@无聊的年

微信公众号:@scixing的炼丹炉

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值