20、C 函数式编程中的编码最佳实践与测试

C# 函数式编程中的编码最佳实践与测试

在函数式编程中,我们追求创建纯函数,即对于相同的输入,函数总是能产生相同的输出。为了编写出更好的函数式代码,我们需要遵循一些最佳实践规则。以下将详细介绍这些规则以及如何在代码中应用它们。

1. 防止不诚实的函数签名

在函数式编程里,我们运用数学方法来构建代码,这要求函数满足两个条件:
- 当提供相同的参数时,函数应始终返回相同的结果。
- 函数的签名要能提供所有可能接受的输入值和可能产生的输出的信息。

下面通过几个示例来理解:

public partial class Program
{
    public static int SumUp(int a, int b)
    {
        return a + b;
    }
}

SumUp 函数对于相同的输入会返回相同的输出,符合数学函数的要求。

public partial class Program
{
    public static int GenerateRandom(int max)
    {
        Random rnd = new Random(Guid.NewGuid().GetHashCode());
        return rnd.Next(max);
    }
}

GenerateRandom 函数即使输入相同,每次调用也会返回不同的结果,不满足数学函数的条件,在创建纯函数时应避免使用。

public partial class Program
{
    public static int Divide(int a, int b)
    {
        return a / b;
    }
}

Divide 函数的签名看似能处理任意两个整数输入,但当 b 为 0 时,会抛出 DivideByZeroException 错误,说明其签名未能提供足够的操作结果信息。我们可以将其重构为:

public partial class Program
{
    public static int? Divide(int a, int b)
    {
        if (b == 0)
            return null;
        return a / b;
    }
}

重构后的 Divide 函数添加了可空类型,避免了 DivideByZeroException 错误。

2. 将可变类重构为不可变类

在函数式编程中,不可变性非常重要,因为可变操作会使代码变得不诚实。不可变性应用于数据结构,如类,意味着该类的对象在其生命周期内不能被更改。

以下是一个可变类的示例:

namespace Immutability
{
    public class UserMembership
    {
        private User _user;
        private DateTime _memberSince;
        public void UpdateUser(int userId, string name)
        {
            _user = new User(userId, name);
        }
    }
    public class User
    {
        public int Id { get; }
        public string Name { get; }
        public User(int id, string name)
        {
            Id = id;
            Name = name;
        }
    }
}

UserMembership 类的 UpdateUser 方法会改变对象的状态,产生副作用。我们可以将其重构为不可变类:

namespace Immutability
{
    public class UserMembership
    {
        private readonly User _user;
        private readonly DateTime _memberSince;
        public UserMembership(User user, DateTime memberSince)
        {
            _user = user;
            _memberSince = memberSince;
        }
        public UserMembership UpdateUser(int userId, string name)
        {
            var newUser = new User(userId, name);
            return new UserMembership(newUser, _memberSince);
        }
    }
    public class User
    {
        public int Id { get; }
        public string Name { get; }
        public User(int id, string name)
        {
            Id = id;
            Name = name;
        }
    }
}

重构后的 UpdateUser 方法不再更新 UserMembership 类的结构,而是创建一个新的实例并返回,消除了副作用,使代码更易读。

3. 避免可变性和时间耦合

有时,带有副作用的方法会损害代码的可读性,方法的调用会相互耦合。

以下是一个存在时间耦合问题的示例:

public class MembershipDatabase
{
    private Address _address;
    private Member _member;
    public void Process(string memberName, string addressString)
    {
        CreateAddress(addressString);
        CreateMember(memberName);
        SaveMember();
    }
    private void CreateAddress(string addressString)
    {
        _address = new Address(addressString);
    }
    private void CreateMember(string name)
    {
        _member = new Member(name, _address);
    }
    private void SaveMember()
    {
        var repository = new Repository();
        repository.Save(_member);
    }
}
public class Address
{
    public string _addressString { get; }
    public Address(string addressString)
    {
        _addressString = addressString;
    }
}
public class Member
{
    public string _name { get; }
    public Address _address { get; }
    public Member(string name, Address address)
    {
        _name = name;
        _address = address;
    }
}
public class Repository
{
    public static List<Member> customers { get; }
    public void Save(Member customer)
    {
        customers.Add(customer);
    }
}

MembershipDatabase 类的 Process 方法中,方法的调用存在时间耦合,必须按特定顺序调用才能正常工作。

我们可以将其重构为:

public class MembershipDatabase
{
    public void Process(string memberName, string addressString)
    {
        Address address = CreateAddress(addressString);
        Member member = CreateMember(memberName, address);
        SaveMember(member);
    }
    private Address CreateAddress(string addressString)
    {
        return new Address(addressString);
    }
    private Member CreateMember(string name, Address address)
    {
        return new Member(name, address);
    }
    private void SaveMember(Member member)
    {
        var repository = new Repository();
        repository.Save(member);
    }
}
public class Address
{
    public string _addressString { get; }
    public Address(string addressString)
    {
        _addressString = addressString;
    }
}
public class Member
{
    public string _name { get; }
    public Address _address { get; }
    public Member(string name, Address address)
    {
        _name = name;
        _address = address;
    }
}
public class Repository
{
    public static List<Member> customers { get; }
    public void Save(Member customer)
    {
        customers.Add(customer);
    }
}

重构后的代码将所有输入和输出明确地指定在方法的签名中,消除了时间耦合。

4. 处理副作用

在函数式编程中,虽然我们需要创建纯函数,但无法完全避免副作用。例如,在 MembershipDatabase 类中, SaveMember 方法会将成员信息保存到数据库,这就是一个副作用操作。

private void SaveMember(Member member)
{
    var repository = new Repository();
    repository.Save(member);
}

为了处理副作用,我们可以使用命令 - 查询分离(CQS)原则,将产生副作用的方法和不产生副作用的方法分开。返回值的方法为查询方法,不会改变任何状态;返回 void 的方法为命令方法,会产生副作用。

MembershipDatabase 类中, Process SaveMember 方法是命令方法,会产生副作用; CreateAddress CreateMember 方法是查询方法,不会改变任何状态。

5. 分离领域逻辑和可变外壳

当代码处理业务事务时,可能会多次修改数据。我们可以将代码分离为领域逻辑和可变外壳。领域逻辑使用数学函数以函数式方式编写业务逻辑,便于测试;可变外壳则放置可变表达式。

以下是一个包含副作用的代码示例:

public class Librarianship
{
    private readonly int _maxEntriesPerFile;
    public Librarianship(int maxEntriesPerFile)
    {
        _maxEntriesPerFile = maxEntriesPerFile;
    }
    public void AddRecord(string currentFile, string visitorName, string bookTitle, DateTime returnDate)
    {
        // The rest of code can be found in the downloaded source code
    }
    private string GetNewFileName(string existingFileName)
    {
        // The rest of code can be found in the downloaded source code
    }
    public void RemoveRecord(string visitorName, string directoryName)
    {
        foreach (string fileName in Directory.GetFiles(directoryName))
        {
            // The rest of code can be found in the downloaded source code
        }
    }
}

Librarianship 类用于记录图书馆借阅信息,包含 AddRecord RemoveRecord 方法。

AddRecord 方法根据日志文件的行数和 _maxEntriesPerFile 的值决定是在现有文件中添加记录还是创建新文件:

if (lines.Length < _maxEntriesPerFile)
{
    int lastIndex = int.Parse(lines.Last().Split(';')[0]);
    string newLine = String.Format("{0};{1};{2};{3}", (lastIndex + 1), visitorName, bookTitle, returnDate.ToString("d"));
    File.AppendAllLines(currentFile, new[] { newLine });
}
else
{
    string newLine = String.Format("1;{0};{1};{2}", visitorName, bookTitle, returnDate.ToString("d"));
    string newFileName = GetNewFileName(currentFile);
    File.WriteAllLines(newFileName, new[] { newLine });
    currentFile = newFileName;
}

GetNewFileName 方法用于生成新的日志文件名:

private string GetNewFileName(string existingFileName)
{
    string fileName = Path.GetFileNameWithoutExtension(existingFileName);
    int index = int.Parse(fileName.Split('_')[1]);
    return String.Format("LibraryLog_{0:D4}.txt", index + 1);
}

RemoveRecord 方法用于从日志文件中移除指定访客的记录:

public void RemoveRecord(string visitorName, string directoryName)
{
    foreach (string fileName in Directory.GetFiles(directoryName))
    {
        string tempFile = Path.GetTempFileName();
        List<string> linesToKeep = File.ReadLines(fileName).Where(line => !line.Contains(visitorName)).ToList();
        if (linesToKeep.Count == 0)
        {
            File.Delete(fileName);
        }
        else
        {
            File.WriteAllLines(tempFile, linesToKeep);
            File.Delete(fileName);
            File.Move(tempFile, fileName);
        }
    }
}

我们可以通过以下代码测试 Librarianship 类:

public partial class Program
{
    public static List<Book> bookList = new List<Book>()
    {
        new Book("Arthur Jackson", "Responsive Web Design"),
        new Book("Maddox Webb", "AngularJS by Example"),
        new Book("Mel Fry", "Python Machine Learning"),
        new Book("Haiden Brown", "Practical Data Science Cookbook"),
        new Book("Sofia Hamilton", "DevOps Automation Cookbook")
    };
}

public struct Book
{
    public string Borrower { get; }
    public string Title { get; }
    public Book(string borrower, string title)
    {
        Borrower = borrower;
        Title = title;
    }
}

public partial class Program
{
    public static void LibrarianshipInvocation()
    {
        Librarianship librarian = new Librarianship(5);
        for (int i = 0; i < bookList.Count; i++)
        {
            librarian.AddRecord(GetLastLogFile(AppDomain.CurrentDomain.BaseDirectory), bookList[i].Borrower, bookList[i].Title, DateTime.Now.AddDays(i));
        }
    }
}

public partial class Program
{
    public static string GetLastLogFile(string LogDirectory)
    {
        string[] logFiles = Directory.GetFiles(LogDirectory, "LibraryLog_????.txt");
        if (logFiles.Length > 0)
        {
            return logFiles[logFiles.Length - 1];
        }
        else
        {
            return "LibraryLog_0001.txt";
        }
    }
}

总结

通过遵循上述最佳实践规则,我们可以编写出更符合函数式编程理念的代码。防止不诚实的函数签名、创建不可变类、避免时间耦合、处理副作用以及分离领域逻辑和可变外壳,这些方法有助于提高代码的可读性、可维护性和可测试性。

流程图:Librarianship 类 AddRecord 方法逻辑

graph TD;
    A[开始] --> B{日志文件行数 < _maxEntriesPerFile};
    B -- 是 --> C[获取最后索引];
    C --> D[生成新记录];
    D --> E[追加记录到现有文件];
    B -- 否 --> F[生成新记录];
    F --> G[生成新文件名];
    G --> H[写入新文件];
    H --> I[更新当前文件名];
    E --> J[结束];
    I --> J;

表格:方法类型及副作用判断

方法名 方法类型 是否产生副作用
Process 命令方法
SaveMember 命令方法
CreateAddress 查询方法
CreateMember 查询方法

6. 测试函数式代码

在函数式编程中,测试代码是确保代码质量和正确性的重要环节。由于我们遵循了前面提到的最佳实践,如创建纯函数、避免副作用等,函数式代码通常更容易测试。

6.1 测试纯函数

纯函数对于相同的输入总是产生相同的输出,这使得它们非常适合进行单元测试。例如,我们之前提到的 SumUp 函数:

public partial class Program
{
    public static int SumUp(int a, int b)
    {
        return a + b;
    }
}

我们可以编写一个简单的单元测试来验证它的正确性:

using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class SumUpTests
{
    [TestMethod]
    public void SumUp_TwoNumbers_ReturnsSum()
    {
        // Arrange
        int a = 2;
        int b = 3;
        int expected = 5;

        // Act
        int result = Program.SumUp(a, b);

        // Assert
        Assert.AreEqual(expected, result);
    }
}

在这个测试中,我们使用了 Microsoft.VisualStudio.TestTools.UnitTesting 框架。首先,我们设置了输入参数 a b ,并计算出预期的结果 expected 。然后,我们调用 SumUp 函数并将结果存储在 result 中。最后,我们使用 Assert.AreEqual 方法来验证 result 是否等于 expected

6.2 测试包含副作用的方法

对于包含副作用的方法,如 MembershipDatabase 类中的 SaveMember 方法,我们需要采用不同的测试策略。由于这些方法会改变系统的状态,我们通常需要模拟这些副作用或者使用测试替身(如模拟对象)来隔离测试。

private void SaveMember(Member member)
{
    var repository = new Repository();
    repository.Save(member);
}

假设我们使用一个模拟框架(如 Moq)来创建 Repository 的模拟对象:

using Moq;
using Microsoft.VisualStudio.TestTools.UnitTesting;

[TestClass]
public class MembershipDatabaseTests
{
    [TestMethod]
    public void SaveMember_CallsRepositorySave()
    {
        // Arrange
        var mockRepository = new Mock<Repository>();
        var membershipDatabase = new MembershipDatabase();
        var member = new Member("John Doe", new Address("123 Main St"));

        // Act
        membershipDatabase.SaveMember(member);

        // Assert
        mockRepository.Verify(r => r.Save(member), Times.Once);
    }
}

在这个测试中,我们使用 Moq 框架创建了一个 Repository 的模拟对象 mockRepository 。然后,我们调用 MembershipDatabase SaveMember 方法。最后,我们使用 Verify 方法来验证 Repository Save 方法是否被调用了一次。

7. 函数式 C# 编码最佳实践总结

为了编写出高质量的函数式 C# 代码,我们可以总结以下最佳实践:

7.1 函数签名诚实性
  • 确保函数的签名能够准确反映其输入和输出,避免隐藏的依赖和副作用。
  • 对于可能抛出异常的情况,在签名中明确表示,如使用可空类型。
7.2 不可变性
  • 优先使用不可变类和数据结构,避免可变状态带来的复杂性和潜在的错误。
  • 对于需要更新状态的操作,通过创建新的对象来实现,而不是修改现有对象。
7.3 避免时间耦合
  • 明确方法的输入和输出,将所有依赖和副作用暴露在方法的签名中。
  • 避免方法之间的隐式依赖,确保代码的可读性和可维护性。
7.4 处理副作用
  • 使用命令 - 查询分离(CQS)原则,将产生副作用的方法和不产生副作用的方法分开。
  • 对于命令方法,明确其副作用,并在代码中进行适当的处理。
7.5 分离领域逻辑和可变外壳
  • 将业务逻辑封装在领域逻辑中,使用纯函数和数学方法进行编写,便于测试和维护。
  • 将可变操作和副作用放在可变外壳中,与领域逻辑分离。

流程图:测试函数式代码流程

graph TD;
    A[开始] --> B{是否为纯函数};
    B -- 是 --> C[编写单元测试验证输出];
    C --> D[测试结束];
    B -- 否 --> E[使用模拟对象或测试替身];
    E --> F[模拟副作用];
    F --> G[验证方法调用];
    G --> D;

表格:最佳实践总结

最佳实践 描述
函数签名诚实性 准确反映输入输出,避免隐藏依赖和副作用
不可变性 使用不可变类和数据结构,更新状态时创建新对象
避免时间耦合 明确方法输入输出,消除隐式依赖
处理副作用 使用 CQS 原则分离命令和查询方法
分离领域逻辑和可变外壳 业务逻辑用纯函数编写,可变操作放外壳中

结论

通过遵循函数式编程的最佳实践,我们可以编写出更健壮、更易于维护和测试的 C# 代码。从防止不诚实的函数签名到处理副作用,每一个步骤都有助于提高代码的质量和可靠性。同时,测试函数式代码也变得更加简单和有效,因为我们减少了代码中的不确定性和副作用。在实际开发中,我们应该不断应用这些最佳实践,以提升我们的编程技能和代码质量。

希望这些原则和方法能够帮助你在函数式 C# 编程中取得更好的成果。通过不断实践和改进,你将能够编写出更加优雅和高效的代码。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值