C#中的函数式编程:递归与纯函数(二)
在序言中,我们提到函数式编程的两大特征:无副作用、函数是第一公民。现在,我们先来深入第一个特征:无副作用。
无副作用是通过引用透明(Referential transparency)来定义的。如果一个表达式满足将它替换成它的值,而程序的行为不变,则称这个表达式是引用透明的。
现在,我们不妨进行一个尝试:我们来实现一些函数,但是这次有一个限制:只能用无副作用的表达式。
先以素数判定为例子,我们要写一个函数bool IsPrime(int n),它返回这个整数是不是素数。简单起见,我们采用最朴素的方法:依次检查2~n-1的整数,如果存在n的因子,则返回false,否则返回true.
这种问题的原始做法是使用循环,但是使用循环需要修改循环变量的值,从而产生副作用。
那怎么办了?有一个和循环关系紧密的概念——递归。递归不会改变变量的值,我们尝试用递归实现。
直接对IsPrime递归似乎不太可行,我们需要写一个辅助方法IsPrimeLoop。这个方法的参数除了n以外还有一个辅助参数acc,这个辅助参数起到类似循环变量的作用,它表示当前我们正在尝试的因子。
那这个函数要怎么实现呢?我们约定从小到大枚举整数,那么当acc == n时,循环就结束了,返回true。若acc != n,则循环继续。接着我们需要判断acc是不是n的因子,如果是,则n不是素数,返回false,否则继续递归循环。
借助这个辅助函数,我们只要调用IsPrimeLoop(n, 2)就可以判断了。代码如下:
private static bool IsPrimeLoop(int n, int acc) => (acc == n) || (n % acc != 0 && IsPrimeLoop(n, acc + 1)); public static bool IsPrime(int n) => n >= 2 && IsPrimeLoop(n, 2);
注意到,这里的辅助函数IsPrimeLoop是私有的,因为这个函数是专门供IsPrime调用的,它的访问范围应该限制在IsPrime内。在C#6及以前,这是做不到的,只能把它设定为类私有尽可能减小访问范围。在C#7,我们可以利用内部函数进一步完善。
public static bool IsPrime(int n) { bool Loop(int acc) => (acc == n) || (n % acc != 0 && Loop(acc + 1)); return n >= 2 && Loop(2); }
这时我们的Loop函数可以省略掉参数n,而且Loop的访问范围被限制在了IsPrime内。这样,我们就能在无副作用的前提下,实现素数的判定函数。
注意到,由于我们的IsPrime函数没有用到任何有副作用的表达式,所以,我们可以保证调用IsPrime也不会产生任何副作用。一般的,如果一个函数满足对它的调用一定是引用透明的,我们称这个函数为纯函数。
下面我们来做一个练习,这里我需要你用递归实现阶乘函数int Fact(int n),当n>0时返回1*2*3*...*n的值,当n<=0时返回1,不考虑结果溢出的情况。你的实现不应该包含有副作用的表达式。
如果你完成了,请往下看。
下面我给出两个你可能的实现
public static int Fact(int n) => n <= 0 ? 1 : n * Fact(n - 1);
public static int Fact(int n) { int Loop(int acc, int result) => acc > n ? result : Loop(acc + 1, result * acc); return Loop(1, 1); }
当然,你的具体写法可能有所不同,但基本上可以归为两类。一类是像第一个那样,利用Fact(n)=n * Fact(n-1)进行递归;还有就是就像第二个那样,通过递归来让参数acc从1到n循环,并乘进一个结果变量result.
直观来看,第一个函数会更“递归”一点,而第二个函数则更像用递归实现的循环。为了进一步揭析这两个实现的区别,我们来手动展开一下两个版本的Fact(5)的递归过程:
版本一:
Fact(5) = 5 * Fact(4)
= 5 * 4 * Fact(3)
= 5 * 4 * 3 * Fact(2)
= 5 * 4 * 3 * 2 * Fact(1)
= 5 * 4 * 3 * 2 * 1 * Fact(0)
= 5 * 4 * 3 * 2 * 1 * 1
= 120
版本二:
Fact(5) = Loop(1, 1)
= Loop(2, 1)
= Loop(3, 2)
= Loop(4, 6)
= Loop(5, 24)
= Loop(6, 120)
= 120
发现没有?版本一的式子会逐渐变长,而版本二的式子长度则保持不变。这是因为,后者是尾递归。尾递归的定义为递归调用被立刻返回的递归。尾递归的特点是它理论上不需要额外的空间存储递归信息,就像我们展开式子那样,尾递归占用的空间是恒定的,而非尾递归调用则需额外的空间储存信息。事实上,尾递归和循环是等价的,因为尾递归可以想象成跳转到函数开头,只不过这个“跳转”是无副作用的。因此,我们可以用尾递归去实现循环,从而去除副作用。由于尾递归具有这种好处,我们通常尽可能的使用尾递归,只有在无法转换成尾递归,或者递归层数不大时,才使用非尾递归。
注意到我前面提到尾递归理论上不需要额外空间,但是很多语言在实现尾递归的时候会消耗栈空间的。比如JVM的尾递归会消耗栈空间,一些诸如Scala等编译到JVM的语言会将尾递归转换成循环从而防止栈溢出。但是C#编译器没有这个操作,那.NET在进行尾递归时会消耗栈空间吗?我们不妨来试一下。我的测试环境是.NET Core,使用之前定义的IsPrime函数,然后给它传入int.MaxValue,运行。
嗯,栈溢出了。
根据目前的实验结果,.NET在实现尾递归时会消耗栈空间。但是我用的是Debug模式,那切换到Release模式会怎样呢?
哈!没有溢出!
从上面实验可以看出,.NET Core在Debug模式下尾递归会消耗栈空间,Release模式不会。
因此,我们可以通过打开Release模式来避免尾递归产生栈溢出错误。
现在,递归相关的知识已经介绍完了。现在我们来讲讲递归的价值。
有的人觉得既然循环可以解决问题,那就没必要花时间去学什么递归;而有的人则觉得循环是魔鬼的,都应该改成递归。事实上,这两种极端的想法都是错误的。
递归的价值在于它能保证你写的函数是纯函数,从而降低一些意外的副作用产生的可能性。还记得序言的那个例子吗?那个程序就可以用尾递归实现来避免bug的产生。
当然,如果你要我写一个阶乘算法,或者写一个素数判断算法,我肯定用for循环。因为这个函数足够简单,我有自信做到,即使我的函数产生了副作用,但是这个副作用只是局部的,整个函数还是纯的函数。
但是,当程序复杂时,尤其是产生闭包时,这些副作用会比较隐晦,此时,使用尾递归能降低代码出错的几率。
尾递归还有一种好处:它能减少代码逻辑上的复杂性。我见过有一些好几重循环嵌套的程序,循环变量之间还相互依赖,逻辑非常复杂。但是,如果你把它改成尾递归,你就需要将循环转为一个或多个递归函数,从而使得逻辑结构更加的清晰。
最后,用一句话总结,递归应该减少你的负担,而不是成为你的负担。
习题:
一、用尾递归改写序言中提到的副作用产生bug的例子。
二、对于斐波那契数列数列fib(n)定义为:当n<=2时,fib(n)=1;当n>2时,fib(n)=fib(n-1)+fib(n-2)。分别用尾递归和非尾递归实现fib,并比较两个实现的效率差异。你能解释其中的原因吗?
学习ASP.NET Core Razor 编程系列四——Asp.Net Core Razor列表模板页面
学习ASP.NET Core Razor 编程系列目录
学习ASP.NET Core Razor 编程系列二——添加一个实体
学习ASP.NET Core Razor 编程系列三——创建数据表及创建项目基本页面
本篇文章介绍上一篇文章中创建的书籍信息管理系统中增删改查的四个Razor模板页面。
一、列表页面。
我们首先来了解一下书籍列表页面,这个页面位置在 Pages/Books/Index.cshtml.cs :
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; using RazorMvcBooks.Models; namespace RazorMvcBooks.Pages.Books { public class IndexModel : PageModel { private readonly RazorMvcBooks.Models.BookContext _context; public IndexModel(RazorMvcBooks.Models.BookContext context) { _context = context; } public IList<Book> Book { get;set; } public async Task OnGetAsync() { Book = await _context.Book.ToListAsync(); } } }
列表页面IndexModel派生自 PageModel。 按照命名规则PageModel 的派生类一般命名为 <PageName>Model。 构造函数使用依赖关系注入的方式将 BookContext 添加到页面。 所有通过上一文章中的方法创建的模板页面都是如此。
当Index页面发出请求时,OnGetAsync 方法向 Razor 页面返回一个书籍列表。 在 Razor 页面上调用 OnGetAsync 方法或 OnGet方法初始化页面数据。 在本示例中,OnGetAsync 将数据库中Book表中的所有书籍信息,并以列表的形式显示出来。
当 OnGet 方法返回 void 或 OnGetAsync方法返回 Task 时,不需要任何返回语句。 当返回类型是 IActionResult 或 Task<IActionResult> 时,必须提供返回语句。
下面我们介绍一下有返回的方法,例如 Pages/Movies/Create.cshtml.cs 页面中的OnPostAsync方法,代码如下:
public async Task<IActionResult> OnPostAsync() { if (!ModelState.IsValid) { return Page(); } _context.Book.Add(Book); await _context.SaveChangesAsync(); return RedirectToPage("./Index"); }
其次,我们来看看书籍列表页面中的前端代码,这个页面位置在 Pages/Books/Index.cshtml,在Visual Studio 2017中使用鼠标左键双击打开 页面,代码如下:
@page @model RazorMvcBooks.Pages.Books.IndexModel @{ ViewData["Title"] = "Index"; } <h2>Index</h2> <p> <a asp-page="Create">Create New</a> </p> <table class="table"> <thead> <tr> <th> @Html.DisplayNameFor(model => model.Book[0].Name) </th> <th> @Html.DisplayNameFor(model => model.Book[0].ReleaseDate) </th> <th> @Html.DisplayNameFor(model => model.Book[0].Author) </th> <th> @Html.DisplayNameFor(model => model.Book[0].Price) </th> <th></th> </tr> </thead> <tbody> @foreach (var item in Model.Book) { <tr> <td> @Html.DisplayFor(modelItem => item.Name) </td> <td> @Html.DisplayFor(modelItem => item.ReleaseDate) </td> <td> @Html.DisplayFor(modelItem => item.Author) </td> <td> @Html.DisplayFor(modelItem => item.Price) </td> <td> <a asp-page="./Edit" asp-route-id="@item.ID">Edit</a> | <a asp-page="./Details" asp-route-id="@item.ID">Details</a> | <a asp-page="./Delete" asp-route-id="@item.ID">Delete</a> </td> </tr> } </tbody> </table>
Razor指令可以根据规则从 HTML 转换为 C# 或 Razor 特定标记。 当在 @ 符号后跟 Razor 保留关键字时,它会转换为 Razor 特定标记,如果不是Razor保留关键字,则会转换为 C#。
@page Razor 指令将文件转换为一个 MVC 操作,这意味着它可以处理请求。 @page 必须是页面上的第一个 Razor 指令。 @page 是转换成 Razor 特定标记的一个示例。
请查看下列HTML助手中使用的lambda表达式:
@Html.DisplayNameFor(model => model.Book[0].Name)
DisplayNameFor HTML辅助助手检查 Lambda 表达式中引用的Name属性来确定显示名称。 Lambda表达式是检查而不是求值。 这意味着当 model、model.Book 或 model.Book[0] 为 null 或为空时,不会存在任何访问冲突。 当使用Html辅助助手取值辅助方法对 Lambda 表达式取值(例如,使用 @Html.DisplayFor(modelItem => item.Title)),将取得该实体的属性值。
@model指令
@page @model RazorMvcBooks.Pages.Books.IndexModel
@model 指令指定传递给 Razor 页面的实体类型。在上面的示例中,@model 使 PageModel 派生的类IndexModel可用于 Razor 页面。 在页面上通过 @Html.DisplayNameFor 和 @Html.DisplayName HTML 辅助助手使用该实体。
ViewData 和布局
首先,我们看一下下面的布局代码:
@page @model RazorMvcBooks.Pages.Books.IndexModel @{ ViewData["Title"] = "Index"; }
上面的代码就是 Razor 代码转换为 C# 的一个示例。 大括号“{ }” 字符括住 C# 代码块。
PageModel 基类具有 ViewData 字典属性,可用于添加要传递到某个视图的数据。 可以使用键/值模式将对象添加到 ViewData 字典。 在上面的示例中,“Title”属性被添加到 ViewData 字典中。 “Title”属性的在 Pages/_Layout.cshtml 文件中使用。
你可以在Visual Studio 2017中打开 位于 Pages/_Layout.cshtml 文件,我们来看看这个文件中的前几行,便可以发现 “Title”的使用。代码如下。
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@ViewData["Title"] - RazorMvcBooks</title> <environment include="Development"> <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" /> <link rel="stylesheet" href="~/css/site.css" /> </environment> @*Markup removed for brevity.*@
行 @*Markup removed for brevity.*@ 为 Razor 注释。 与 HTML 注释不同 (<!-- -->),Razor 注释不会发送到客户端。
在Visual Studio 2017 中按F5运行应用程序,在浏览器测试项目中的链接(Home、About、Contact、Create、Edit和Delete)。你会发现在浏览器中每个页面的标题都是一样的。当您将某个页面添加到书签时,标题用于这个书签。 Pages/Index.cshtml 和 Pages/Books/Index.cshtml 当前具有相同的标题,但可以修改它们以具有不同的值。
我们可以在Visual Studio 2017中打开 位于 Pages/_ViewStart.cshtml 文件,查看其中的 Layout 属性,如下代码:
@{ Layout = "_Layout"; }
上面的标记将所有 Razor 页面的布局设置为 Pages 文件夹下的 Pages/_Layout.cshtml所定义的布局。
修改布局
第一步,我们要使用“书籍管理系统”来替换 Pages/_Layout.cshtml 文件中的 <title> 元素中的RazorMvcBooks字符串。如下代码。
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>@ViewData["Title"] – 书籍管理系统</title>
第二步,在 Pages/_Layout.cshtml 文件中的找到以下数据信息:
<a asp-page="/Index" class="navbar-brand">RazorMvcBooks</a>
将上面的数据信息替换为以下数据信息:
<a asp-page="/Books/Index" class="navbar-brand">书籍管理系统</a>
上面的<a>元素是一个标记辅助助手。此处它是<a>标记辅助助手。asp-page="/Books/Index" 标记辅助助手属性和值可以用来创建指向 /Books/Index Razor 页面的链接。
保存所做的修改,在Visual Studio 2017中按F5运行程序,在浏览器中通过单击“书籍管理系统”链接测试应用,如下图。