在C#语言中拥抱函数式编程

目录

介绍

什么是函数式编程?

是什么导致了软件中的错误?

示例 1

示例 2

我们为什么要谈论函数式编程?

什么是纯函数?

信息

重要

为什么纯度很重要?

函数式编程与数学函数相连

为什么会选择使用C#进行函数式编程?为什么不使用Haskell、Erlang或F#?

Option概念介绍

此代码有什么问题?

好的,但是我们该怎么做呢?Enter选项

信息

引入任一概念


介绍

制作软件是一项具有挑战性的工作,而创建高质量的软件则更加艰巨。这就是为什么许多书籍专门讨论该主题,旨在简化开发过程,并且经常提出新的架构范式来增强数据组织。同时,测试驱动开发等方法已在业界广泛采用。

尽管如此,近几十年来,随着多核处理器的出现,一个趋势在某种程度上被忽视了,那就是倾向于采用函数式编程,而不是命令式或面向对象的编程进行软件开发。我们在本系列中的目标是准确地揭示这种范式的含义以及为什么它值得更广泛地采用。此外,我们将探讨其陡峭的学习曲线带来的挑战,这导致了其采用速度的较慢。

随后的教科书被证明对结束本系列很有用,将函数式编程作为一个综合主题来讨论。

本文最初发表于此处:在C#中拥抱函数式编程,或此文

什么是函数式编程?

函数式编程经常被吹捧为解决程序中所有错误的灵丹妙药;它有助于提高可读性、可测试性和可维护性。虽然这在特定情况下是正确的,但深入研究商业宣传背后的基本原理至关重要。

是什么导致了软件中的错误?

简明扼要地回答这个问题是具有挑战性的。Bug可能由暂时性故障、无响应的服务、不可缩放的组件或其他可用性和性能问题触发。事实上,我们在这里指的是由代码中的缺陷设计引发的内在错误。其中,一个比较成问题的问题是状态突变。

  • 状态突变是指修改对象或变量状态的过程。它涉及更改对象的值或属性,通常会导致可能影响程序行为的副作用。
  • 状态突变在命令式编程范式中很普遍,其重点是描述分步过程和操作变量的状态。

状态突变的挑战在于它引入了复杂性,并使推理程序行为变得更加困难。当代码库的不同部分修改相同的状态时,可能会出现意想不到的后果,从而导致难以跟踪和调试的错误。

示例 1

请考虑以下计算列表中整数之和的C#代码。

public static int Sum(List<int> list) {
    var sum = 0;
    foreach (var x in list)
        sum += x;
    return sum;
}

这段代码没有任何错误,现在,想象一下我们需要一个计算绝对值总和的方法。为了防止重复,我们重用了前面的函数。

public static int SumAbsolute(List<int> list) {    
    for (var i = 0; i < list.Count(); ++i)
        list[i] =  Math.Abs(list[i]);
    return Sum(list);
}

现在我们可以在main程序中使用这些方法。

C#

static void Main(string[] args)
{
    var data = new List<int>() { -1, 0 };
    Console.WriteLine($"Sum of absolute values: {SumAbsolute(data)}");
    Console.WriteLine($"Sum of values: {Sum(data)}");
}

核心问题就在这里,因为该SumAbsolute方法直接改变输入列表,而无需事先复制其值。在我们的上下文中,检测问题相对简单,但请考虑包含数千行代码库的潜在复杂性。

示例 2

此示例摘自 Functional Programming in C#(Buonanno)。

public class Product
{
    int inventory;
    
    public bool IsLowOnInventory { get; private set; }
    public int Inventory
    {
        get { return inventory; }
        private set
        {
            inventory = value;
            // At this point, the object can be in an invalid state, 
            // from the perspective of any thread reading its properties.
            IsLowOnInventory = inventory <= 5;
        }
    }
}

在多线程设置中,存在一个简短的窗口,其中清单已更新,但IsLowOnInventory尚未更新。这种情况很少发生,但这是可能的,在这种情况下,调试变得非常具有挑战性。这些麻烦的bug被称为竞争条件,如果可以检测到,则检测起来非常具有挑战性。虽然人们可能会认为我们的代码通常是单线程的,但不幸的是,多核处理器正在使并发变得越来越普遍。

事实上,向多核机器的转变是我们目前看到的对FP重新产生兴趣的主要原因之一。
C#中的函数式编程(Buonanno)

相比之下,不可变性,即一旦创建对象,其状态就无法更改,有助于创建更可预测和抗错误的代码。稍后会详细介绍。

我们为什么要谈论函数式编程?

我们刚刚观察到,状态突变会导致某些程序出现重大错误。现在,让我们考虑一个单变量的简单实值函数ff,使得f(x)=x2+x+1F(1)的结果是什么?

这个问题看起来很简单,结果是33。我们如何计算?我们取1,平方,加1,然后再次加1。为什么要大惊小怪?

第二种计算方法看起来完全是荒谬的:然而,这正是我们在整个计算过程中改变变量状态时偶尔参与的(在我们的场景中,最初,x等于1,然后,在操作过程中,x变得等于2)。虽然以这种方式表达时听起来微不足道,但不可否认的是,它反映了实际情况。

在评估特定值的数学函数时,该值在整个计算过程中保持不变。事实上,这与函数式编程的核心概念之一相一致,也是其名称的一个基本方面:结果将仅取决于其参数,而与任何全局状态无关。这个概念被称为纯度,函数式编程努力避免状态突变以保持纯度。

什么是纯函数?

纯函数是指在给定相同输入的情况下,将始终产生相同输出并且没有可观察到的副作用的函数。

  • 函数的输出完全由其输入决定。
  • 该函数不修改任何外部状态或变量。它不依赖或更改其范围之外的任何内容(无副作用)。

纯函数是函数式编程中的一个基本概念,它促进了代码的可预测性、可测试性和推理的便利性。

这就是为什么在像F#这样的纯函数式语言中,变量是严格不可变的,一旦初始化就无法更改。在C#中,设计本身并不能保证不可变性,我们必须采用替代方法来维护这一基本属性。

信息

最近流行的编程语言Rust本质上并不是一种函数式语言,但变量在默认情况下是不可变的。这承认了状态突变被确定为错误的基本来源之一。

重要

在计算机科学中,副作用是不可避免的。始终需要修改数据库或文件;否则,我们的工作将完全没有意义。函数式编程的理念就是将这些影响隔离在不纯函数中,并用纯函数对其他所有内容进行编码。

为什么纯度很重要?

纯洁不仅仅是一个哲学概念。通过纯函数,并行性得到了显着简化,因为我们不必考虑副作用或任何全局状态。这就是为什么函数式编程被视为增强并发性的原因。同样,使用纯函数进行测试变得非常简单,从而也增强了可测试性。

我们将在本系列的最后一篇文章中探讨这一方面。

函数式编程与数学函数相连

因此,函数式编程是一种避免突变的范式。然而,与数学函数的联系远不止于此。

在定义函数f时,我们理解它的域A(它可以计算的值)并知道它的潜在返回值B。此映射表示为f:AB

在编程语言中,同样的原则也应该适用:我们应该能够预测我们使用的任何函数或过程的结果。但是,情况总是如此吗?

int Divide(int x, int y)
{
    return x / y;
}

签名声明函数接受两个整数并返回另一个整数。但并非在所有情况下都是如此。如果我们调用像Divide(1, 0)这样的函数会发生什么?函数实现不遵守其签名,抛出DivideByZeroExceptionC#中的函数式编程(Honest functions in functional programming with C#

重要

诚实的函数是准确传达有关其可能的输入和输出的所有信息的函数,始终如一地遵循其签名,没有任何隐藏的依赖关系或副作用。

我们怎样才能使这个功能成为一个诚实的功能?我们可以更改y参数的类型(NonZeroInteger是一种自定义类型,可以包含除零以外的任何整数)。

诚实的职能不仅仅是一个哲学概念。精确地了解函数返回的内容可以链接此函数,就像在数学意义上组合函数一样。因此,诚实或引用透明度增强了代码的可预测性、可测试性和推理性。

LINQ确实是.NET框架中以函数式样式编写的代码的一个示例。LINQ允许我们有效地链接或组合函数。

var res = accounts.Where(x => x.IsActive).Select(t => t.Name).OrderByDescending();

为什么会选择使用C#进行函数式编程?为什么不使用HaskellErlangF#

这是一个很好的问题。为什么不用HaskellF#或其他编程语言编程,因为它们太棒了?问题在于,我们必须考虑现实:C#开发人员过剩,而F#工程师却非常稀缺。

没有一家公司会冒着无法招聘的风险,因为它采用了一种没有人精通的奇妙范式(事实上,这种选择只适用于大型企业)。这就是为什么做出妥协的原因,坚持使用C#,并尝试将函数式编程纳入这种语言中。

信息

C#主要是一种面向对象的语言,可以作为两个世界之间的桥梁。

信息

在本系列中,我们将利用该LaYumba.Functional库,该库可作为NuGet包访问。

Option概念介绍

作为开发人员,我们经常使用基础库中的常用方法,而不会质疑它们。例如,将字符串转换为整数是一种常见的操作,如以下代码所示:

var input = Console.ReadLine();
var s = Convert.ToInt32(input);
Console.WriteLine(s);

此代码将为输入(如1230-2)生成整数输出。但是,当提供像hello这样的输入时,应用程序会产生什么结果?

此代码举例说明一个不诚实的函数;其名称和签名均不表示可以引发异常。我们只预期一个整数的返回,当最终发现错误时,开发人员将对原始代码进行轻微的修改。

var input = Console.ReadLine();
if (int.TryParse(input, out var s))
{
    Console.WriteLine(s);
}
else
    Console.WriteLine("An error occurred.");

此代码有什么问题?

这段代码本身并没有什么问题。它按预期运行,并有助于防止错误。但是,输出值很可能是另一个函数的输入(通常,代码不仅仅是为了娱乐而编写的),在这种情况下,我们需要处理两个分支:一个用于正确的输入,另一个用于不正确的输入。这引入了if-else语句和可能嵌套的if-else语句,最终使代码不可读,因此难以维护。

相反,函数式编程建议使用精确已知输出的诚实函数。如果方法可能返回异常,则必须在其签名中显式指示该异常。这种方法允许我们在不诉诸if-else语句的情况下组合函数,并且与其数学对应物密切相关。在数学中组合多个函数时,指示可能发生错误的情况并不常见。

代码变得更加清晰,因为我们可以链接函数。

好的,但是我们该怎么做呢?Enter选项

在函数式编程中,Option是一种数据类型,表示值的存在与否。它是一种处理没有结果或未定义结果的可能性的方法,而无需求助于null

  • Option类型可以是Some(value),指示存在值,也可以是None,指示不存在值。
  • Option类型的使用有助于创建更诚实和可预测的函数,因为它强制显式处理缺少值的情况。这可以使代码更可靠、更不容易出错。

信息

选项在文献中有时被称为也许

有了这个概念,我们可以定义一个诚实的函数,用于将string转换为整数。

private static Option<int> ConvertToInt32(string input)
{
    return int.TryParse(input, out var s) ? Some(s) : None;
}

签名现在清楚地表明,可以不获取数据(例如,由于异常),与使用null或异常相比,提供了一种更干净、更明确的方式来处理潜在的值缺失。因此,结果可以在下游使用以继续工作流程,而无需许多ifelse条件。

var input = Console.ReadLine();
var res = ConvertToInt32(input);
Console.WriteLine(res);

这种编码方法允许我们自然地链接函数,从而产生更具可读性的代码。例如,请考虑以下方案:

  • 读取字符串
  • 将其转换为整数
  • 价值翻倍

使用Option,代码与前面的算法非常相似。

var input = Console.ReadLine();
var res = ConvertToInt32(input).Map(x => x * 2);
Console.WriteLine(res);

无论转换过程中发生错误还是一切顺利进行,我们都将获得不需要复杂分支逻辑的结果。

信息

有关C#Option概念的具体实现,请参阅 C#中的函数式编程(Buonanno)。

引入任一概念

当我们想对数据缺失进行建模时,这个Option概念是合适的。但是,在某些情况下,我们需要更多信息,特别是要了解为什么没有价值。当程序可以引发不同的异常时,这一点变得尤为重要。

要么是表示以下两个可能值之一的数据类型:LeftRight。它通常用于对可能导致成功(Right)或失败(Left)的计算进行建模。约定是将Left用于错误或失败情况,而将Right用于成功情况。使用Either的好处是,它允许我们在Left情况下包含错误值,从而提供了有关失败的更多信息。这在可能发生多种类型的错误的情况下会很有帮助,我们希望区分它们。

使用Either,我们可以重写前面的代码。

var input = Console.ReadLine();
var res = ConvertToInt32(input).Map(x => x * 2);
Console.WriteLine(res);

private static Either<string, int> ConvertToInt32(string input)
{
    try
    {
        return Right(Convert.ToInt32(input));
    }
    catch (Exception ex)
    {
        return Left(ex.Message);
    }
}

信息

我们只是触及了函数式编程为提高可读性而提供的各种可能性的表面。在这个简短的概述中,我们不能详尽或过于严格地处理错综复杂的细节(如单子)。为了更深入地了解这些概念,建议参考有关该主题的专门书籍。

现在,我们将探讨如何利用数据进行功能性思维。但是,为了避免本文过载,对此实现感兴趣的读者可以在此处找到续篇。

https://www.codeproject.com/Articles/5376714/Embracing-Functional-Programming-in-Csharp

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值