C#基础3- 面向对象中级应用3

1.命令行参数 args 与 Main (string \[\] args)

知识点

  1. 核心定位 :命令行参数是控制台应用与外部系统 (CI/CD 流水线、定时调度器、运维脚本、 批处理 平台)交互的标准入口,广泛应用于数据迁移、文件归档、批量任务、配置注入等企业场景。
  2. 入口机制 :Main 是 C# 程序的唯一入口点,string[] args 接收调用方传入的参数序列,参数以空格分隔,带双引号的内容会被识别为单个参数。
  3. 企业级用途:通过命令行传入输入 / 输出路径、运行环境、配置文件地址、批量处理阈值、执行模式等,替代硬编码配置,实现程序的多环境复用。
  4. 进程交互 :推荐使用 static int Main(string[] args) 签名,通过返回值向调用方传递执行状态码(0 = 成功,非 0 = 各类错误),支撑自动化流程的状态判断。

示例:企业文件批量归档工具

实现一个可通过命令行调用的文件归档工具,支持压缩 / 移动两种模式、后缀过滤、递归子目录,可直接集成到 Windows 任务计划或 Jenkins 流水线中。

csharp 复制代码
using System;
using System.IO;
using System.IO.Compression; // 用于ZipFile压缩
using System.Linq;

namespace EnterpriseTools.FileArchiver
{
    class Program
    {
        // 带整型返回值的Main,向操作系统返回执行状态
        static int Main(string[] args)
        {
            try
            {
                // ========== 1. 命令行参数校验 ==========                 
                // 参数至少4个:源目录、目标目录、模式、后缀列表,可选参数 -r
                if (args.Length < 4)
                {
                    Console.WriteLine("用法: FileArchiver.exe <源目录> <目标目录> 
                        <模式:zip/move> <后缀列表> [-r]");
                    Console.WriteLine("示例: FileArchiver.exe "D:\Reports" \
                                                              "E:\Archive" zip .pdf,.xlsx -r");
                    return 1; // 参数错误码
                }

                string sourceDir = args[0];// 源目录
                string targetDir = args[1];// 目标目录
                string mode = args[2].ToLower();// 执行模式 zip=压缩归档  move=移动文件
                // 分割后缀字符串,删除空项;如 .pdf,.xlsx → [".pdf",".xlsx"]
                string[] extensions = args[3].Split(',', StringSplitOptions.RemoveEmptyEntries);
                // 是否递归扫描子目录,带-r就开启递归
                bool recursive = args.Contains("-r");

                if (!Directory.Exists(sourceDir))
                {
                    Console.WriteLine($"错误: 源目录不存在 - {sourceDir}");
                    return 2; // 路径不存在码
                }

                Directory.CreateDirectory(targetDir);
                // 根据 -r 参数,选择搜索方式:递归全部子目录 / 仅当前一层目录
                var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;

                // ========== 2. 筛选文件 ==========
                // 枚举源目录下所有文件,过滤出后缀匹配的文件(忽略大小写)
                var files = Directory.EnumerateFiles(sourceDir, "*.*", searchOption)
                    .Where(f => extensions.Any(ext => f.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
                    .ToList();

                // ========== 3. 分支:zip压缩模式 ==========
                if (mode == "zip")
                {
                    // 在目标目录生成带时间戳的zip包 archive_20261017_143022.zip
                    string zipPath = Path.Combine(targetDir, $"archive_{DateTime.Now:yyyyMMdd_HHmmss}.zip");
                    // using自动释放Zip资源
                    using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create))
                    {
                        foreach (var file in files)
                        {
                            // 计算相对路径:保证压缩包内目录结构和源目录保持一致,不是扁平一堆文件
                            string entryName = Path.GetRelativePath(sourceDir, file);
                            // 将文件写入zip包,entryName是包内文件路径
                            archive.CreateEntryFromFile(file, entryName);
                        }
                    }
                    Console.WriteLine($"压缩完成,共归档 {files.Count} 个文件 -> {zipPath}");
                }
                // ========== 4. 分支:move移动模式 ==========
                else if (mode == "move")
                {
                    foreach (var file in files)
                    {
                        // 获取文件相对于源目录的路径,保留原目录层级
                        string relativePath = Path.GetRelativePath(sourceDir, file);
                        string destPath = Path.Combine(targetDir, relativePath);
                        // 如果目标子文件夹不存在,先创建文件夹
                        Directory.CreateDirectory(Path.GetDirectoryName(destPath));
                        // 移动文件,第三个参数true:目标文件存在时直接覆盖
                        File.Move(file, destPath, true);
                    }
                    Console.WriteLine($"移动完成,共处理 {files.Count} 个文件");
                }
                // ========== 5. 不支持的模式 ==========
                else
                {
                    Console.WriteLine($"错误: 不支持的模式 - {mode}");
                    return 3; // 返回码3:模式参数非法
                }

                return 0; // 返回码0:执行成功(操作系统约定0代表成功)
            }
            catch (Exception ex)
            {
                // 捕获所有异常,输出消息,返回负的错误码
                Console.WriteLine($"执行异常: {ex.Message}");
                return -1; // 返回码-1:通用未知异常
            }
        }
    }
}

① 文件查询:Directory.EnumerateFiles + Where + Any + ToList

ini 复制代码
var files = Directory.EnumerateFiles(sourceDir, "*.*", searchOption)
    .Where(f => extensions.Any(ext => f.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
    .ToList();

. Directory.EnumerateFiles(sourceDir, "*.*", searchOption)

  • 作用:枚举目录下的文件路径字符串,不是一次性把全部路径加载到内存。
  • 参数: sourceDir:从哪个目录开始查找。

"*.*":匹配所有文件名(后续再过滤后缀)。

searchOption:AllDirectories递归子目录 / TopDirectoryOnly仅一层。

和 Directory.GetFiles 的区别: EnumerateFiles 是延迟迭代 ,遍历的时候才逐个返回路径;文件极多时内存占用更低。GetFiles一次性把所有路径读入数组。

.Where(f => ...)

LINQ 过滤,只保留满足条件的文件。f:代表每一个文件的完整路径字符串(如D:\Reports\a.pdf)

extensions.Any(ext => f.EndsWith(ext, StringComparison.OrdinalIgnoreCase))

extensions:命令行传入的后缀数组,例如 [".pdf", ".xlsx"]

Any():数组里只要任意一个后缀满足条件就返回 true

f.EndsWith(ext, StringComparison.OrdinalIgnoreCase) 判断文件路径是否以该后缀结尾 ,OrdinalIgnoreCase = 忽略大小写 (.PDF 和 .pdf 视为一样)

.ToList()

把 LINQ 延迟查询结果,立即执行查询,放到 List集合。

如果不加 ToList:后面循环的时候会重复扫描磁盘;ToList 一次性拿到全部符合条件文件列表,方便后面计数、遍历。

遍历源目录(可选递归),筛选出后缀在指定后缀列表中的文件,把文件全路径存入 List。

② 打开 Zip 包

ini 复制代码
var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create)

ZipFile 在 System.IO.Compression

  • zipPath:zip 文件完整路径(例如E:\Archive\archive_20261017_143022.zip)
  • ZipArchiveMode.Create:新建压缩包。如果文件已存在 → 直接覆盖。

Read:只读打开已有 zip。Update:读写,可以增删改 zip 内部文件。

返回值 ZipArchive:代表整个压缩包对象,操作 zip 的入口。

注意:一般搭配using:using(var archive = ZipFile.Open(...)) using 会在代码块结束自动调用 Dispose,把缓冲区写入磁盘、关闭文件流;不 using 容易出现 zip 损坏 / 文件被锁。

③ 相对路径 + 添加文件进压缩包

ini 复制代码
string entryName = Path.GetRelativePath(sourceDir, file);
// 将文件写入zip包,entryName是包内文件路径
archive.CreateEntryFromFile(file, entryName);

Path.GetRelativePath(sourceDir, file)

核心目的:获取相对路径,用来保留目录层级

sourceDir:根源目录 D:\Reports。

file:文件绝对路径 D:\Reports\2026\sales.pdf。

返回:2026\sales.pdf。

如果不用这个,直接传文件名Path.GetFileName(file),压缩包里所有文件会全部平铺在根目录,丢失文件夹结构。

archive.CreateEntryFromFile(file, entryName)

参数 1 file:磁盘上源文件的绝对路径,要打包的真实文件。

参数 2 entryName:压缩包内部的虚拟路径(条目名) ,zip 里看到的路径。

功能:读取磁盘文件,写入 zip 压缩包;在 zip 内部创建这个条目。

术语:ZipArchiveEntry 就是 zip 里面的单个文件 / 目录条目。

整体串联逻辑

扫描目录,筛选后缀匹配的文件,拿到所有文件全路径列表。创建一个全新的 zip 文件,得到 ZipArchive 对象。

遍历每一个文件: 算出该文件相对于源根目录的相对路径。把文件写入 zip,使用相对路径作为包内路径,保留文件夹树。

语法注意事项与最佳实践

  1. args 数组下标从 0 开始,不包含程序自身路径 (与 C/C++ 不同),第一个元素是程序名后的第一个参数。
  2. 未传参时 args 是长度为 0 的空数组,而非 null,访问前必须判断长度,避免 IndexOutOfRangeException。

要点:程序直接双击运行 /cmd 不带任何参数启动,args 不会是 null,而是 string[0]。 ❗ 不会空引用异常,但直接访问 args 0 会报索引越界 IndexOutOfRangeException。

ini 复制代码
static int Main(string[] args)
{
    Console.WriteLine(args == null);       // false
    Console.WriteLine(args.Length);        // 0

    // args长度为0,直接args[0] → IndexOutOfRangeException
    // string first = args[0]; 

    // ✅ 规范:先判断长度
    if(args.Length < 1)
    {
        Console.WriteLine("缺少参数");
        return 1;
    }
    string first = args[0];
    return 0;
}
  1. 包含空格的路径 / 参数必须用双引号包裹,CLR 会自动将引号内内容识别为单个参数。

命令行里空格默认是参数分隔符 。 示例程序 Tool.exe:

bash 复制代码
# ❌ 错误写法,会被拆成多个参数
Tool.exe D:\My Reports\data.pdf
# 解析结果:args[0] = D:\My,args[1]=Reports\data.pdf

# ✅ 双引号包裹,引号内整体当成一个参数
Tool.exe "D:\My Reports\data.pdf"
# 解析结果:args[0] = D:\My Reports\data.pdf
  1. Main 方法有 4 种合法签名,企业开发优先使用 static int Main(string[] args),便于脚本化调用。

4 种合法写法:

csharp 复制代码
// 1. 无参void
static void Main(){}
// 2. 带参数void
static void Main(string[] args){}
// 3. 无参int(返回退出码)
static int Main(){ return 0; }
// 4. 带参数int(企业首选 ✅)
static int Main(string[] args){ return 0; }

为什么优先第 4 种:返回 int 退出码,批处理 / PowerShell 脚本读取%errorlevel%判断程序成功失败,自动化脚本必备。 return 0 = 成功;非 0 = 各类错误。

  1. 所有参数默认都是字符串,数值、布尔类型需手动转换,必须做格式校验与异常捕获。

args 数组所有元素永远是 string,不会自动转 int/decimal/bool。

示例:想传入数字--count 100

csharp 复制代码
// args[1] 是字符串 "100",要自己转
if(args.Length <2) return 1;
string strCount = args[1];

// ❌ 直接强制转换,输入非数字直接抛异常
// int count = int.Parse(strCount);

// ✅ 安全写法:TryParse,校验格式
if(!int.TryParse(strCount, out int count))
{
    Console.WriteLine("count参数不是合法数字");
    return 1;
}

如果用户传--count abc,int.Parse直接崩溃;TryParse可以优雅捕获。

  1. 最佳实践:参数超过 3 个时,应封装专门的参数解析类,支持长短参数名、默认值、自动生成帮助文档。

当参数变多:源目录、目标目录、模式、后缀、-r、超时、覆盖标记... 直接写一堆args[0],args[1],args[2]可读性极差,维护困难。

封装参数类示例:

csharp 复制代码
// 独立参数模型,把所有配置放这里
public class ArchiverOptions
{
    public string SourceDir { get; set; }
    public string TargetDir { get; set; }
    public string Mode { get; set; }
    public string[] Extensions { get; set; }
    public bool Recursive { get; set; }
}

// 单独写解析方法,负责解析args、校验、输出帮助
public static ArchiverOptions ParseArgs(string[] args)
{
    // 解析长短参数 -r / --recursive,校验必填项,生成帮助文本
}

好处:支持长短参数:-r 和 --recursive 等效。可以设置默认值。统一校验,统一输出 help 文档,不用在 Main 里堆大量 if 判断。代码解耦,方便单元测试参数解析逻辑。

2.综合示例:一个完整的类(字段 + 属性 + 方法 + 构造)

知识点

  1. 类的完整设计规范:一个合格的业务实体类应包含

「私有状态字段 +

带验证的公共属性 +

多场景构造函数 +

业务行为方法 +

基础方法重写」,确保对象始终处于合法状态。

  1. 封装核心:

字段存储内部状态且私有,

属性控制读写权限并加入业务校验,

构造函数强制初始化必填项,

方法封装业务规则。

  1. 构造链复用 :通过 this() 实现多个构造函数之间的代码复用,减少重复逻辑,保证初始化逻辑一致。

示例:采购订单业务类

实现企业采购系统中的核心订单类,包含金额校验、折扣计算、明细管理、订单号生成等完整业务逻辑。

csharp 复制代码
using System;
using System.Collections.Generic;
using System.Linq;

namespace EnterpriseProcurement
{
    // 采购订单领域实体类(DDD领域模型风格)
    public class PurchaseOrder
    {
        // ========== 私有字段,封装内部状态,外部无法直接访问 ==========
        // readonly:订单号一旦构造完成就固定,不能修改
        private readonly string _orderId;
        private decimal _totalAmount; // 本代码里此字段实际未使用,总价改为实时计算
        // 订单明细集合,私有,外部不能直接Add/Remove
        private readonly List<OrderItem> _items = new List<OrderItem>();
        private decimal _discountRate; // 折扣率内部存储字段

        // ========== 公共属性,对外暴露,控制读写权限 ==========
        // 只读属性:只有get,只能在构造函数赋值,订单号创建后永不修改
        public string OrderId => _orderId;

        public string SupplierName { get; set; }
        public DateTime CreateTime { get; }
        public string Creator { get; set; }

        /// <summary>
        /// 折扣率属性:带业务校验,写的时候强制范围0~1(0=无折扣,0.8=8折)
        /// </summary>
        public decimal DiscountRate
        {
            get => _discountRate;
            set
            {
                // 业务规则校验,非法值直接抛出参数越界异常
                if (value < 0 || value > 1)
                    throw new ArgumentOutOfRangeException(nameof(value), "折扣率必须在0-1之间");
                _discountRate = value;
            }
        }

        /// <summary>
        /// 只读集合:对外暴露明细,外部只能遍历,不能直接Add/Remove
        /// AsReadOnly()包装原有List,外部调用Items.Add会编译报错
        /// </summary>
        public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

        /// <summary>
        /// 折后总金额:动态计算属性,**不存储在字段**
        /// 每次访问TotalAmount时,实时遍历明细求和,再应用折扣
        /// 优点:明细改动后总价自动同步,不会出现数据不一致
        /// </summary>
        public decimal TotalAmount
        {
            get
            {
                // Sum(单价*数量)得到原始总价,再乘以(1-折扣率)
                return _items.Sum(i => i.UnitPrice * i.Quantity) * (1 - DiscountRate);
            }
        }

        // ========== 构造函数 ==========
        /// <summary>
        /// 主构造函数:新建订单时必填核心参数,创建时校验,保证实体一出世就是合法状态
        /// </summary>
        public PurchaseOrder(string supplierName, string creator)
        {
            // 防御性校验:构造时如果传入空,直接抛异常,不创建非法订单对象
            if (string.IsNullOrWhiteSpace(supplierName))
                throw new ArgumentException("供应商名称不能为空", nameof(supplierName));
            if (string.IsNullOrWhiteSpace(creator))
                throw new ArgumentException("创建人不能为空", nameof(creator));

            _orderId = GenerateOrderId();    // 自动生成订单编号
            SupplierName = supplierName;
            Creator = creator;
            CreateTime = DateTime.Now;       // 记录订单创建时间,创建后不可修改
            _discountRate = 0;               // 默认无折扣
        }

        /// <summary>
        /// 重载构造函数:支持创建订单时一次性传入一批明细
        /// : this() 语法,调用本类上面的主构造,复用校验和初始化逻辑
        /// 避免两份构造写重复初始化代码
        /// </summary>
        public PurchaseOrder(string supplierName, string creator, IEnumerable<OrderItem> items)
            : this(supplierName, creator)
        {
            if (items == null) throw new ArgumentNullException(nameof(items));
            _items.AddRange(items);
        }

        // ========== 私有辅助方法,外部不可调用 ==========
        /// <summary>
        /// 生成订单号:时间戳+随机数,私有,外部不能修改生成规则
        /// </summary>
        private string GenerateOrderId()
        {
            return $"PO{DateTime.Now:yyyyMMddHHmmss}{new Random().Next(1000, 9999)}";
        }

        // ========== 公共业务方法:实体对外暴露行为,不开放内部字段 ==========
        /// <summary>
        /// 添加订单明细,在方法内部做业务校验
        /// 外部不能直接操作_items集合,只能通过AddItem添加
        /// </summary>
        public void AddItem(string productName, decimal unitPrice, int quantity)
        {
            if (string.IsNullOrWhiteSpace(productName))
                throw new ArgumentException("商品名称不能为空", nameof(productName));
            if (unitPrice <= 0)
                throw new ArgumentOutOfRangeException(nameof(unitPrice), "单价必须大于0");
            if (quantity <= 0)
                throw new ArgumentOutOfRangeException(nameof(quantity), "数量必须大于0");

            _items.Add(new OrderItem(productName, unitPrice, quantity));
        }

        /// <summary>
        /// 应用折扣,调用DiscountRate属性,复用属性里面的范围校验
        /// 不用重复写一遍0~1的判断逻辑
        /// </summary>
        public void ApplyDiscount(decimal rate)
        {
            DiscountRate = rate;
        }

        /// <summary>
        /// 获取订单摘要信息,格式化输出订单汇总文本
        /// </summary>
        public string GetOrderSummary()
        {
            return $"订单号:{OrderId} | 供应商:{SupplierName} | 明细数:{Items.Count} | 折后总价:{TotalAmount:C}";
        }

        /// <summary>
        /// 嵌套类:订单项OrderItem,属于PurchaseOrder内部明细
        /// 明细是不可变对象:ProductName、UnitPrice、Quantity全部是只读,创建后不能修改
        /// </summary>
        public class OrderItem
        {
            public string ProductName { get; }
            public decimal UnitPrice { get; }
            public int Quantity { get; }

            public OrderItem(string productName, decimal unitPrice, int quantity)
            {
                ProductName = productName;
                UnitPrice = unitPrice;
                Quantity = quantity;
            }
        }
    }
}

语法注意事项与最佳实践

  1. 字段默认访问级别为 private,推荐显式声明并采用 _驼峰命名,与属性明确区分。
  2. 属性不是字段的简单包装,必须承担校验、转换、计算职责;只读属性只声明 get,禁止无意义的公开读写属性。
  3. 一旦定义了有参构造函数,编译器不再生成默认无参构造;如需无参构造必须显式声明。
  4. 对象初始化器 new Class { Prop = value } 在构造函数执行后赋值,无法替代构造函数的必填校验。
  5. 集合属性推荐返回 IReadOnlyList 等只读接口,防止外部直接修改内部集合状态,破坏封装。
  6. 最佳实践:核心业务实体必须通过构造函数传入必填参数,确保「对象创建即合法」。

多个类与简单分工(小项目拆解)

知识点

  1. 单一职责原则(SRP) :每个类只负责一项职责,将复杂系统拆分为多个高内聚、低耦合的类,是企业代码可维护性的基础。

  2. 典型职责拆分:

    1. 实体类:存放业务数据,不包含复杂逻辑
    2. 服务类:封装业务规则与计算逻辑
    3. 工具类:提供通用辅助功能
    4. 入口类:负责流程编排与对象协作
  3. 反模式警示:禁止出现「上帝类」(一个类包含所有逻辑),会导致代码无法测试、无法并行开发、需求变更成本极高。

示例:员工薪资核算系统

将薪资核算业务拆分为 4 个职责清晰的类,模拟企业 HR 系统的薪资计算模块。

csharp 复制代码
using System;

namespace EnterpriseHR
{
    // 职责1:员工实体类 - 仅存放员工基础数据
    public class Employee
    {
        public string EmployeeId { get; }
        public string Name { get; }
        public int Level { get; } // 职级
        public decimal BaseSalary { get; }
        public decimal PerformanceFactor { get; set; } // 绩效系数

        public Employee(string employeeId, string name, int level, decimal baseSalary)
        {
            EmployeeId = employeeId;
            Name = name;
            Level = level;
            BaseSalary = baseSalary;
            PerformanceFactor = 1.0m;
        }
    }

    // 职责2:薪资计算服务类 - 封装所有薪资计算规则
    public class SalaryCalculator
    {
        private const decimal SocialInsuranceRate = 0.105m; // 五险一金个人比例
        private const decimal TaxThreshold = 5000m;

        public decimal CalculateGrossSalary(Employee employee)
        {
            // 应发工资 = 基本工资 * 绩效系数 + 职级补贴
            decimal levelAllowance = employee.Level * 500;
            return employee.BaseSalary * employee.PerformanceFactor + levelAllowance;
        }

        public decimal CalculateDeduction(decimal grossSalary)
        {
            // 扣除项 = 五险一金 + 个税
            decimal socialInsurance = grossSalary * SocialInsuranceRate;
            decimal taxableIncome = grossSalary - socialInsurance - TaxThreshold;
            decimal tax = taxableIncome > 0 ? CalculatePersonalIncomeTax(taxableIncome) : 0;
            return socialInsurance + tax;
        }

        public decimal CalculateNetSalary(Employee employee)
        {
            decimal gross = CalculateGrossSalary(employee);
            decimal deduction = CalculateDeduction(gross);
            return Math.Round(gross - deduction, 2);
        }

        // 私有辅助方法:个税计算(内部逻辑,不对外暴露)
        private decimal CalculatePersonalIncomeTax(decimal taxableIncome)
        {
            if (taxableIncome <= 3000) return taxableIncome * 0.03m;
            if (taxableIncome <= 12000) return taxableIncome * 0.1m - 210;
            if (taxableIncome <= 25000) return taxableIncome * 0.2m - 1410;
            if (taxableIncome <= 35000) return taxableIncome * 0.25m - 2660;
            if (taxableIncome <= 55000) return taxableIncome * 0.3m - 4410;
            if (taxableIncome <= 80000) return taxableIncome * 0.35m - 7160;
            return taxableIncome * 0.45m - 15160;
        }
    }

    // 职责3:工资单生成类 - 负责格式化输出
    public class SalarySlipGenerator
    {
        private readonly SalaryCalculator _calculator;

        // 依赖注入:通过构造传入计算服务,而非内部new
        public SalarySlipGenerator(SalaryCalculator calculator)
        {
            _calculator = calculator;
        }

        public string GenerateSlip(Employee employee, int month)
        {
            decimal gross = _calculator.CalculateGrossSalary(employee);
            decimal deduction = _calculator.CalculateDeduction(gross);
            decimal net = _calculator.CalculateNetSalary(employee);

            return $@"
=====================================
          {month}月 工资条
=====================================
员工编号: {employee.EmployeeId}
员工姓名: {employee.Name}
职级: {employee.Level}
-------------------------------------
应发工资: {gross:C}
扣除合计: {deduction:C}
-------------------------------------
实发工资: {net:C}
=====================================";
        }
    }

    // 职责4:入口类 - 流程编排
    class Program
    {
        static void Main(string[] args)
        {
            var calculator = new SalaryCalculator();
            var generator = new SalarySlipGenerator(calculator);

            var emp = new Employee("E001", "张三", 5, 15000m)
            {
                PerformanceFactor = 1.2m
            };

            string slip = generator.GenerateSlip(emp, 10);
            Console.WriteLine(slip);
        }
    }
}

语法注意事项与最佳实践

  1. 同一命名空间下的类可直接互相访问,跨命名空间需通过 using 引用。
  2. 类之间的依赖应通过构造函数或方法参数传入 ,而非在内部直接 new,降低耦合度,便于单元测试。
  3. 禁止循环依赖(A 依赖 B,B 依赖 A),出现时应提取公共逻辑到第三类解耦。
  4. 纯工具类推荐声明为 static 静态类,无需实例化,仅提供静态方法。
  5. 最佳实践:一个 cs 文件只放一个类,文件名与类名完全一致,便于项目管理与代码检索。

4.本单元小结:类与对象练习

知识点

  1. 核心概念落地:类是抽象模板,对象是具体实例;类定义结构与行为,对象承载业务数据与状态。
  2. 对象生命周期:构造函数创建 → 方法调用交互 → GC 自动回收,企业开发需关注对象初始化合法性与空引用安全。
  3. 引用类型本质:类是引用类型,对象赋值是传递内存地址,多个变量可指向同一个实例,修改会互相影响。
  4. 综合能力:能根据业务需求抽象出类与类的关联关系,合理分配职责,构建可扩展的领域模型。

示例:客户订单管理综合系统

综合运用类、对象、属性、方法、构造、集合,实现客户与订单的关联管理,包含等级折扣、历史消费统计等业务逻辑。

csharp 复制代码
using System;
using System.Collections.Generic;
using System.Linq;

namespace EnterpriseCRM
{
    /// <summary>
    /// 客户实体:保存客户信息、订单集合、客户等级,自动计算等级与折扣
    /// </summary>
    public class Customer
    {
        // 私有订单列表,外部不能直接修改,只能通过AddOrder添加
        private readonly List<Order> _orders = new List<Order>();

        /// <summary>客户编号,创建后不可修改</summary>
        public string CustomerId { get; }
        /// <summary>客户名称,可修改</summary>
        public string Name { get; set; }
        /// <summary>客户等级 1~5,私有set,外部不能直接赋值,只能内部自动计算</summary>
        public int Level { get; private set; }

        /// <summary>对外暴露只读订单集合,外部不能Add/Remove,保护内部数据</summary>
        public IReadOnlyList<Order> Orders => _orders.AsReadOnly();

        /// <summary>构造函数:新建客户,默认等级1</summary>
        public Customer(string customerId, string name)
        {
            CustomerId = customerId;
            Name = name;
            Level = 1;
        }

        /// <summary>添加订单到客户,添加后自动重新计算客户等级</summary>
        /// <param name="order">订单对象</param>
        public void AddOrder(Order order)
        {
            // 参数校验:禁止传入null订单
            if (order == null) throw new ArgumentNullException(nameof(order));
            _orders.Add(order);
            UpdateLevel(); // 添加订单后,更新客户等级
        }

        /// <summary>私有方法:根据客户所有订单【实付总额】自动更新客户等级</summary>
        private void UpdateLevel()
        {
            // 汇总所有订单FinalAmount(折扣后实付金额)
            decimal totalSpent = _orders.Sum(o => o.FinalAmount);

            // 模式匹配switch 自动判断等级
            Level = totalSpent switch
            {
                < 10000 => 1,        // 累计消费 <1万 等级1
                < 50000 => 2,        // 1万~5万 等级2
                < 100000 => 3,       // 5万~10万 等级3
                < 300000 => 4,       // 10万~30万 等级4
                _ => 5               // >=30万 等级5
            };
        }

        /// <summary>获取当前客户折扣率</summary>
        /// <returns>折扣小数:0=无折扣,0.12代表88折</returns>
        public decimal GetDiscountRate()
        {
            return Level switch
            {
                1 => 0m,      // 无折扣
                2 => 0.02m,   // 98折
                3 => 0.05m,   // 95折
                4 => 0.08m,   // 92折
                5 => 0.12m,   // 88折
                _ => 0m
            };
        }

        /// <summary>获取客户全部订单累计实付总金额</summary>
        public decimal GetTotalConsumption()
        {
            return _orders.Sum(o => o.FinalAmount);
        }
    }

    /// <summary>订单实体:一张订单,归属某个客户,包含多条订单项</summary>
    public class Order
    {
        // 私有订单项集合
        private readonly List<OrderItem> _items = new List<OrderItem>();

        public string OrderId { get; }               //订单号
        public DateTime CreateTime { get; }           //订单创建时间
        public Customer Owner { get; }               //归属客户
        public IReadOnlyList<OrderItem> Items => _items.AsReadOnly(); //只读订单项

        /// <summary>原始总价:未打折,所有商品小计之和</summary>
        public decimal OriginalAmount => _items.Sum(i => i.Subtotal);

        /// <summary>实付金额:原始金额 × (1 - 客户折扣率)</summary>
        public decimal FinalAmount
        {
            get
            {
                decimal discount = Owner.GetDiscountRate();
                return OriginalAmount * (1 - discount);
            }
        }

        /// <summary>构造订单,绑定客户,自动生成订单号、记录创建时间</summary>
        public Order(Customer owner)
        {
            Owner = owner ?? throw new ArgumentNullException(nameof(owner));
            // 订单号:ORD + 当前时间戳,简单避免重复
            OrderId = $"ORD{DateTime.Now:yyyyMMddHHmmssfff}";
            CreateTime = DateTime.Now;
        }

        /// <summary>向订单添加商品项</summary>
        public void AddItem(string productName, decimal price, int quantity)
        {
            _items.Add(new OrderItem(productName, price, quantity));
        }
    }

    /// <summary>订单项:订单里面的单行商品</summary>
    public class OrderItem
    {
        public string ProductName { get; }  //商品名称
        public decimal UnitPrice { get; }    //单价
        public int Quantity { get; }        //购买数量
        public decimal Subtotal => UnitPrice * Quantity; //商品小计

        public OrderItem(string productName, decimal unitPrice, int quantity)
        {
            ProductName = productName;
            UnitPrice = unitPrice;
            Quantity = quantity;
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            //1. 创建客户
            var customer = new Customer("C001", "甲公司");

            //2. 创建第一张订单,添加商品
            var order1 = new Order(customer);
            order1.AddItem("服务器", 12000, 2);
            order1.AddItem("操作系统", 3000, 2);
            customer.AddOrder(order1); //订单加入客户,自动更新客户等级

            //3. 创建第二张订单
            var order2 = new Order(customer);
            order2.AddItem("数据库授权", 25000, 1);
            customer.AddOrder(order2);

            //输出信息
            Console.WriteLine($"客户等级: {customer.Level}");
            Console.WriteLine($"累计消费: {customer.GetTotalConsumption():C}");
            Console.WriteLine($"当前折扣: {customer.GetDiscountRate():P0}");
            Console.WriteLine($"最新订单实付: {order2.FinalAmount:C}");
        }
    }
}

语法注意事项与最佳实践

  1. 引用类型默认值为 null,调用任何成员前必须做空值检查,否则抛出 NullReferenceException。

  2. 集合属性推荐在定义时直接初始化(如 List<Order> _orders = new();),避免添加元素时报空引用。

  3. 对象之间通过方法交互,禁止直接修改对方内部字段,保持封装边界。

封装边界:只用方法交互,不直接访问对方内部字段

原则:私有字段不对外暴露;外部只能通过公开方法 / 只读属性访问,不能直接写内部集合、私有变量

Customer 内部订单集合

scss 复制代码
// 私有字段,外部完全碰不到
private readonly List<Order> _orders = new List<Order>();
// 对外暴露只读包装,外部不能 Add/Remove
public IReadOnlyList<Order> Orders => _orders.AsReadOnly();

✅ 添加订单不能写 customer._orders.Add() ,只能调用公开方法:

ini 复制代码
customer.AddOrder(order1);

AddOrder() 是唯一入口,内部做完参数校验 + 更新等级。

外部无法直接修改客户内部订单列表,对象之间只能通过方法交互。

Order 内部订单项集合

ini 复制代码
private readonly List<OrderItem> _items = new List<OrderItem>();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

添加商品不能直接操作 _items,只能调用:

arduino 复制代码
order1.AddItem("服务器", 12000, 2);

属性设计:禁止外部随意修改内部状态

csharp 复制代码
// CustomerId 只读,构造后不能改
public string CustomerId { get; }
// Level 只有 private set,外部不能 customer.Level = 5;
public int Level { get; private set; }

Level 只能由客户自己的私有方法 UpdateLevel() 修改;

外部代码不能直接写客户等级,业务规则封装在 Customer 内部。

所有核心集合、状态字段都是 private;修改行为全部封装到类的方法;外部只能调用方法,不能直接读写对方私有成员。

  1. 双向关联(客户有订单,订单有客户)需注意循环引用,序列化和内存释放时要特别处理。

双向关联:

Customer → 持有多个 Order(_orders 列表存 Order 实例)

Order → 持有一个 Customer(public Customer Owner { get; })

css 复制代码
Customer 实例  ←→  Order实例
Customer._orders 里面存 Order
Order.Owner 存 Customer

这就是双向引用,形成对象图循环:客户引用订单,订单又回头引用客户。

对应代码位置

Order 构造函数接收 Customer 并保存:

csharp 复制代码
public Customer Owner { get; }

public Order(Customer owner)
{
    Owner = owner ?? throw new ArgumentNullException(nameof(owner));
}

Customer.AddOrder 把 Order 加入自己内部列表

vbnet 复制代码
private readonly List<Order> _orders = new List<Order>();
public void AddOrder(Order order)
{
    if (order == null) throw new ArgumentNullException(nameof(order));
    _orders.Add(order);
    UpdateLevel();
}

对象引用链: customer 对象内部列表保存 order;order.Owner 指向同一个 customer。循环引用。

两个风险点:序列化 + 内存释放

序列化(JSON 等)遇到循环引用

如果直接用 Newtonsoft.Json/ System.Text.Json 序列化 Customer: 序列化 Customer → 遍历 Orders → 序列化 Order → 读取 Owner → 又回到 Customer,无限递归,直接抛异常。

解决方案:序列化时忽略其中一端(比如序列化订单时忽略 Owner,或者序列化客户时忽略 Orders)。

.NET GC 内存释放

⚠️ 重点: .NET GC 不怕简单循环引用 (这点很多人会搞错) GC 不是 "计数引用",是可达性标记。 只要外部没有根引用同时指向 Customer 和 Order,GC 照样可以把循环引用的一组对象整体标记回收。

但坑在这些场景:

如果放到静态集合 / 缓存里面持有引用,循环对象永远无法释放,造成内存泄漏;

非托管资源 + 双向引用 + IDisposable 手动释放时,容易出现:一方 Dispose 了,另一方还持有对象,访问已释放对象报错;

事件订阅:双向对象互相订阅对方事件,叠加循环引用,更容易内存泄漏。

当前双向关联的一个小缺陷

现在代码里,建立双向关系只做了一半:

css 复制代码
var order = new Order(customer); // order.Owner = customer
customer.AddOrder(order);        // customer._orders.Add(order)

✅ 手动两处绑定,才能完成双向关联。 ❌ 隐患:如果忘记调用 customer.AddOrder(order) ,Order.Owner 已经指向客户,但是客户列表里没有这个订单,双向关系不一致,对象图状态脏了。

规范写法(领域实体标准做法):把双向绑定收敛到一处,在 AddOrder 内部维护两边关系:

scss 复制代码
public void AddOrder(Order order)
{
    if (order == null) throw new ArgumentNullException(nameof(order));
    // 防御:防止重复添加
    if (_orders.Contains(order)) return;
    
    _orders.Add(order);
    // 【重要】强制保证order的Owner一定是当前客户,维护双向一致性
    // 但Owner现在是只读属性,不能赋值!这就是当前代码的短板
    UpdateLevel();
}

当前代码Owner是{get;}只读,一旦 Order 构造完成,Owner 不能修改,所以无法在 AddOrder 里校验并绑定。

两种方案: 方案 A:Owner 内部 set,只能在实体内部维护关联; 方案 B:单向关联,Order 只保存 CustomerId,不保存 Customer 对象(彻底消除循环引用,很多项目选择这个)。

最佳实践:领域模型中优先使用单向关联,减少系统复杂度。

5.封装与访问修饰符:public、private

知识点

  1. 封装的本质:隐藏内部实现细节,暴露稳定的对外接口,保护对象内部状态的一致性(不变量),是面向对象三大特性之首。
  2. public:公开访问级别,对所有代码可见,用于定义类的对外契约(公共方法、只读属性)。
  3. private:私有访问级别,仅在当前类内部可见,用于隐藏字段、辅助方法、实现细节,是实现封装的主要手段。
  4. 最小暴露原则:能私有就私有,只对外暴露必要的操作;所有字段必须私有,禁止直接公开数据字段。

示例:银行账户类

通过封装保证账户余额的安全性,外部只能通过存款、取款、转账等公共方法操作,无法直接修改余额,杜绝非法状态。

csharp 复制代码
using System;

namespace EnterpriseBanking
{
    public class BankAccount
    {
        // 私有字段:核心状态完全对外隐藏
        private readonly string _accountNumber;
        private decimal _balance;
        private readonly string _accountHolder;
        private int _transactionCount;

        // 公共只读属性:仅暴露查询,不允许外部修改
        public string AccountNumber => _accountNumber;
        public string AccountHolder => _accountHolder;
        public decimal Balance => _balance;
        public int TransactionCount => _transactionCount;

        // 构造函数:初始化账户,保证创建即合法
        public BankAccount(string accountNumber, string accountHolder, decimal initialBalance)
        {
            if (string.IsNullOrWhiteSpace(accountNumber))
                throw new ArgumentException("账号不能为空", nameof(accountNumber));
            if (string.IsNullOrWhiteSpace(accountHolder))
                throw new ArgumentException("开户人不能为空", nameof(accountHolder));
            if (initialBalance < 0)
                throw new ArgumentOutOfRangeException(nameof(initialBalance), "初始余额不能为负");

            _accountNumber = accountNumber;
            _accountHolder = accountHolder;
            _balance = initialBalance;
            _transactionCount = 0;
        }

        // 公共方法:对外提供的合法操作
        public void Deposit(decimal amount)
        {
            ValidateAmount(amount);
            _balance += amount;
            _transactionCount++;
        }

        public bool Withdraw(decimal amount)
        {
            ValidateAmount(amount);
            if (_balance < amount)
                return false; // 余额不足

            _balance -= amount;
            _transactionCount++;
            return true;
        }

        public bool Transfer(BankAccount targetAccount, decimal amount)
        {
            if (targetAccount == null)
                throw new ArgumentNullException(nameof(targetAccount));
            if (targetAccount.AccountNumber == _accountNumber)
                throw new InvalidOperationException("不能向自己转账");

            if (!Withdraw(amount))
                return false;

            targetAccount.Deposit(amount);
            return true;
        }

        // 私有辅助方法:内部校验逻辑,不对外暴露
        private void ValidateAmount(decimal amount)
        {
            if (amount <= 0)
                throw new ArgumentOutOfRangeException(nameof(amount), "交易金额必须大于0");
            if (amount > 1000000)
                throw new ArgumentOutOfRangeException(nameof(amount), "单笔交易金额超限");
        }
    }
}

语法注意事项与最佳实践

  1. 类成员默认访问级别为 private,显式声明可提升代码可读性。
  2. 类的默认访问级别为 internal ,而非 public ;跨程序集访问必须显式声明 public class。
  3. 同一个类的不同实例之间可以互相访问私有成员,这是 C# 的语言规则。

internal 的含义:仅在【当前程序集 (assembly)】内部可见

  • ✅ 同一个项目(同一个 dll/exe)里,所有代码都可以访问这个 internal 类,随便实例化、继承
  • ❌ 别的项目(别的程序集)引用当前 dll,看不到这个类,无法使用
  1. 属性可以单独为 get/set 指定访问级别,如 public decimal Balance { get; private set; },实现外部只读、内部可写。
  2. 误区警示:封装 ≠ 字段私有 + 对应属性。封装的核心是控制状态合法性,而非单纯的语法包装。
  3. 最佳实践 :核心业务字段使用 readonly 修饰不可变部分(如账号),防止意外修改。
相关推荐
代码小学僧1 小时前
如何低成本部署网站?Vercel 和 Cloudflare 怎么选?
前端·后端·serverless
MetWeave1 小时前
从零跑一个气象数据管道:抓取、清洗、对账、节奏
后端·python
gopher95112 小时前
RUST:序列化与反序列化 serde + derive 详解
开发语言·后端·rust
铁皮饭盒2 小时前
CPU就行, 老照片变清晰, 本地部署582mb腾讯的模型, 本地部署, 过节了给长辈们变个小魔术
前端·javascript·后端
步步为营DotNet2 小时前
Async 状态机要搬进运行时了:.NET 10 Runtime Async 实测,快的地方快 1.5 倍,贵的地方贵 5 倍
性能优化·c#·.net·async·runtime
七夜zippoe2 小时前
Spring Boot 接通义千问:从拿 Key 到第一个对话接口跑通(保姆级)
java·spring boot·后端
SimonKing2 小时前
别再花钱调 OCR 接口了!这个国产开源工具,免费还能离线跑
java·后端·程序员
Nebula_g2 小时前
SpringBoot企业级实战项目—云禾外卖—SpringBoot常用注解解析
java·网络·数据库·spring boot·后端·spring·javase
hixiong1233 小时前
C# 使用 ZXingCpp.Angle 进行条码、二维码识别
开发语言·c#·扫码