【C#/F#】【函数式编程】第三讲:我们应该如何使用记录这个类型?- 数据与逻辑分离

在.NET Core时代, C#迎来了数次重量级更新。其中在C#9中迎来了“记录(Record)”类型,它相比于类,拥有了一些类似于结构的特性(可以直接比较两者是否数据相等),也可以用于数据传输的轻量型类型。

但是这真的是记录类型的全部吗?

什么,你问我怎么突然在说这个?

还记得吗,不可变性,函数式编程中一个关键的概念。如果还有认真看过前几期内容的一定也会对数据与逻辑分离这件事印象深刻。//那么今天我们会一步一步来看,记录与函数式编程有什么样的关联

除了上述特性记录还拥有一个很重要的特性,非破坏性更新。

public record Person(string FirstName, string LastName);

  Person One = new Person("One", "One");
  Person Two = One with { FirstName = "Two" };
  Console.WriteLine(One);
  Console.WriteLine(Two);
// output:
Person { FirstName = One, LastName = One }
Person { FirstName = Two, LastName = One }

记录可以轻松的是用with表达式创造出修改一部分属性/字段的副本。(可以看到我们并没有修改原来one的值)

并且记录使用主构造函数的情况下,其属性默认只读不可变。

记录也只是简单数据的聚合(组合),尽管记录可以像类一样包含各种方法,但记录实际只是简单数据的聚合。

默认的行为是非常重要的一件事,语言也许可以做到多方面的事,我们也有办法让类的属性只读或者记录的属性可变。但默认的行为会使某一件事达成的顺理成章

而记录的默认行为与其特性,顺理成章的让它成为了一个非常函数式的类型。它与生俱来就拥有不可变数据与逻辑分离的能力。

有句古话说得好,人不可能踏进同一条河流两次,因为河流一直在变化,所以我们每次踏入的都不是同一条河流。看起来显得有点诡辩,但函数式编程其实就是遵从了这个思想。

那么究竟有什么意义呢,我们从一个简单的任务看起

现在我们希望描述一个圆,让我们先看看OOP是怎么做的

public class Circle
{
    public double Radius { get; set; }
}

目前看起来一切正常,我们精确地描述了这个圆。现在我们有一个需求,希望能够放大或者缩小这个圆。这也简单,我们会毫不犹豫的加上一个方法

public class Circle
{
    public double Radius { get; set; }
    public void Scale(double factor)
    {
        Radius *= factor;
    }
}

看起来棒极了,我们完美的完成了这个任务。现在进行到我们的下一步,我们希望变化后能还原回我们原来的圆。

图片

好像一下愣住了,似乎没有很好的办法做到这一点,如果让用户反向计算回去感觉非常的呆,也可能会发生精度问题。现在我们回归本源思考一下的话,会不会觉得,一个圆就应该是一个圆?他就不应该发生半径的变化这种荒谬的事?如果它半径发生了改变,那就不是同一个圆。

人不能踏进同一个坑,它可以被理解成我们不能重复犯错,但如果我们用函数式的角度去理解,坑如果被我们踏进过了,坑的状态也就改变了,所以我们无法踏进同一个坑

图片

好像哪里怪怪的

那么我们遵从这个河流的思想,用上我们的记录类型,就可以发现这个问题迎刃而解了。

public record Circle(double Radius);

Circle circle = new(1.0);
var newCircle = circle.Scale(2.0);

public static class CircleExtensions
{
    public static Circle Scale(this Circle circle, double factor) 
      => circle with { Radius = circle.Radius * factor };
}

在此处,我们特意的将函数与记录分离开编写,不仅是本身可以通过扩展方法的方式去调用,也是为了强调数据与逻辑分离这一概念,记录只需要简单,精准的去给数据建模就可以了,不需要有各种方法可以去改变自己的状态。这种情况下,一条记录就代表着一组数据,这个时候,我们自然关心的是值是否相等,而不是两者是否为同一个引用(就像忒修斯之船,我们用函数式的角度来看是一个非常容易解答的问题,只要替换了一块木板,他就已经不是原来的船了,而是一个全新的船)

是不是感觉到似乎一切都串联了起来?

不可变,主构造函数,非破坏性更新,值相等。如果在这之前看到记录的特性,可能只会觉得有趣。但了解之后便能发现记录进入C#之后,它为C#打开一扇新的大门,通往函数式编程的大门。

让我们继续观看下一个例子

这是用oop的方式为一种简单的书的数据建模

public class Page
{
    public int PageIndex { get; set; }
    public string Content { get; set; }
    public Page(int pageIndex, string content)
    {
        PageIndex = pageIndex;
        Content = content;
    }
}
public class Book
{
    public string Title { get; set; }
    public string Author { get; set; }
    public int Price { get; set; }
    public List<Page> Pages { get; set; }
    public Book(string title, string author, int price, List<Page> pages)
    {
        Title = title;
        Author = author;
        Price = price;
        Pages = pages;
    }
}

如果我们用记录去为同样的事物建模

public record Page(int PageIndex, string Content);

public record Book(
  string Title,
  string Author,
  int Price,
  ImmutableList<Page> Pages
  );

 就可以简单的得到这部分代码

首先我们创建一些新书

string[] authors = [ 
    ..Enumerable.Repeat("Scixing", 6),
    ..Enumerable.Repeat("Moob", 6),
    ..Enumerable.Repeat("BoredYear", 6)
    ] ;


var books = authors
    .Select(author => 
        new Book("Book", author, 10,
            [.. Enumerable.Range(1, Random.Shared.Next(20))
                .Select(s => new Page(s, $"Page {s}"))]
        )
        )
    .ToList();

(这里的创建方式其实也颇具函数式风采,可以对比一下常规的oop写法)

我们现在需要在所有书的最后加一页尾页,作为一种新的书,

对于原来的书,我们需要给标题加上尖括号,并且过滤出所有作者是Scixing或者是Moob并页数大于7的存在

经过之前圆的例子,相信大家在这里已经可以理解到,OOP非常习惯于去修改对象的状态,像是和人要喝水一样的自然,但这样其实不是一件好事。这意味着,我们会丢失之前对象的状态。会让历史变得无迹可寻,FP则注重于不可变性,所有的状态都不应该发生突变。或者说,所有状态都是有价值的,我全都要.jpg。

FP下可以轻松的做到这一点

books
    .Select(BookExtensions.AddBookTitleMarker)
    .Where(s => s.Author == "Scixing" || s.Author == "Moob")
    .Where(s => s.Pages.Count > 7)
    .ToList()
    .ForEach(Console.WriteLine);
;

books
    .Select(BookExtensions.AddBookTitleMarker)
    .Where(s => (s.Author == "Scixing" || s.Author == "Moob") && s.Pages.Count > 7)
    .ToList()
    .ForEach(Console.WriteLine);
    
public static class BookExtensions
{
    public static Book AddTailPage(this Book book, Page page) => book with { Pages = book.Pages.Add(page) };
    public static Book Slice(this Book book, int start, int count) => book with { Pages = [.. book.Pages.Skip(start).Take(count)] };
    public static Book AddBookTitleMarker(this Book book) => book with { Title = $"<<{book.Title}>>" };
}

上下两种写法都可以接受,第一种写法如果在你随时可能希望注释一部分的时候 可能会有更好的发挥。

不过这只是很传统的写法,我们还可以使用函数式编程的一个秘密武器,模式匹配

books
    .Where
    (s => s is { 
        Author: "Scixing" 
        or "Moob",
        Pages.Count: > 7 
    })
    .Select(BookExtensions.AddBookTitleMarker)
    .Select(b => b.Slice(1, 5))
    .ToList()
    .ForEach(Console.WriteLine);

模式这个概念可能不太容易直观的理解,在这种情况下,有时我个人喜欢称呼其为状态匹配,因为其实质上是在对状态做了一个判断,如果我们看到他编译后生成的代码,可以看到实际上只是编译器帮我们去实现了相对复杂的状态判定。没错,脏活累活就该交给编译器做,我们更应该关注的是解决方案(声明式编程)

这就是我们又一块函数式编程的拼图,模式匹配

用上以上的工具,改变我们的编程的习惯,如果你开始习惯于用简单,较小,不可变的记录,去精确的描述我们的数据,为状态建模。再将逻辑代码与纯粹的数据分开以后。恭喜你,你开始领悟函数式编程的核心了

在F#这个天生的FP语言中我们在大多数情况下也能更轻松的做到这些事

// For more information see https://aka.ms/fsharp-console-apps
type Circle = {Radius: double}

type Page = { PageIndex: int
              Content: string
             }

type Book = {
    Author: string
    Title: string
    Price: int
    Pages: Page list
}


let authors =  (List.init 6 (fun _ -> "Scixing"))
               @ (List.init 6 (fun _ -> "Moob"))
               @ (List.init 6 (fun _ -> "BoredYear"))
let books = authors
            |> List.map (fun c -> {Author = c; Title = "Book"; Pages = []; Price  = 10 })



module Tools =
    let scale (factor: double) (circle: Circle) =
        {circle with Radius =  circle.Radius * factor}

    let addTailPage (book: Book) =
        { book with
            Pages = { PageIndex = book.Pages.Length + 1; Content = "test" } :: book.Pages }
    let slice start count (book: Book) =
        {book with Pages = book.Pages |> List.skip start |> List.take count }

    let addBookTitle symbol (book: Book) =
        {book with Title = symbol + book.Title + symbol}

open Tools
books |> List.map Tools.addTailPage
      |> List.iter (printfn "%A")


books |> List.map Tools.addTailPage
      |> List.iter (printfn "%A")

books |> List.filter
             (fun s ->
                match s with
                    | { Author = ("Scixing" | "BoredYear")
                        Pages = pages} when List.length pages > 12  -> true
                    | _ -> false )
      |> List.iter (printfn "%A")
      

(C#有几个模式确实很好用F#还没类似支持的)

Bilibili: @无聊的年

公众号: @scixing的炼丹炉

github: @ssccinng https://github.com/ssccinng/FPStarter

视频 【C#/F#】【函数式编程】(三)我们应该怎么使用记录(record)类型?- 数据与逻辑分离_哔哩哔哩_bilibili

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值