1.命令行参数 args 与 Main (string \[\] args)
知识点
- 核心定位 :命令行参数是控制台应用与外部系统 (CI/CD 流水线、定时调度器、运维脚本、 批处理 平台)交互的标准入口,广泛应用于数据迁移、文件归档、批量任务、配置注入等企业场景。
- 入口机制 :
Main是 C# 程序的唯一入口点,string[] args接收调用方传入的参数序列,参数以空格分隔,带双引号的内容会被识别为单个参数。 - 企业级用途:通过命令行传入输入 / 输出路径、运行环境、配置文件地址、批量处理阈值、执行模式等,替代硬编码配置,实现程序的多环境复用。
- 进程交互 :推荐使用
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,使用相对路径作为包内路径,保留文件夹树。
语法注意事项与最佳实践
args数组下标从 0 开始,不包含程序自身路径 (与 C/C++ 不同),第一个元素是程序名后的第一个参数。- 未传参时
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;
}
- 包含空格的路径 / 参数必须用双引号包裹,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
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 = 各类错误。
- 所有参数默认都是字符串,数值、布尔类型需手动转换,必须做格式校验与异常捕获。
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可以优雅捕获。
- 最佳实践:参数超过 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.综合示例:一个完整的类(字段 + 属性 + 方法 + 构造)
知识点
- 类的完整设计规范:一个合格的业务实体类应包含
「私有状态字段 +
带验证的公共属性 +
多场景构造函数 +
业务行为方法 +
基础方法重写」,确保对象始终处于合法状态。
- 封装核心:
字段存储内部状态且私有,
属性控制读写权限并加入业务校验,
构造函数强制初始化必填项,
方法封装业务规则。
- 构造链复用 :通过
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;
}
}
}
}
语法注意事项与最佳实践
- 字段默认访问级别为
private,推荐显式声明并采用_驼峰命名,与属性明确区分。 - 属性不是字段的简单包装,必须承担校验、转换、计算职责;只读属性只声明
get,禁止无意义的公开读写属性。 - 一旦定义了有参构造函数,编译器不再生成默认无参构造;如需无参构造必须显式声明。
- 对象初始化器
new Class { Prop = value }在构造函数执行后赋值,无法替代构造函数的必填校验。 - 集合属性推荐返回
IReadOnlyList等只读接口,防止外部直接修改内部集合状态,破坏封装。 - 最佳实践:核心业务实体必须通过构造函数传入必填参数,确保「对象创建即合法」。
多个类与简单分工(小项目拆解)
知识点
-
单一职责原则(SRP) :每个类只负责一项职责,将复杂系统拆分为多个高内聚、低耦合的类,是企业代码可维护性的基础。
-
典型职责拆分:
- 实体类:存放业务数据,不包含复杂逻辑
- 服务类:封装业务规则与计算逻辑
- 工具类:提供通用辅助功能
- 入口类:负责流程编排与对象协作
-
反模式警示:禁止出现「上帝类」(一个类包含所有逻辑),会导致代码无法测试、无法并行开发、需求变更成本极高。
示例:员工薪资核算系统
将薪资核算业务拆分为 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);
}
}
}
语法注意事项与最佳实践
- 同一命名空间下的类可直接互相访问,跨命名空间需通过
using引用。 - 类之间的依赖应通过构造函数或方法参数传入 ,而非在内部直接
new,降低耦合度,便于单元测试。 - 禁止循环依赖(A 依赖 B,B 依赖 A),出现时应提取公共逻辑到第三类解耦。
- 纯工具类推荐声明为
static静态类,无需实例化,仅提供静态方法。 - 最佳实践:一个 cs 文件只放一个类,文件名与类名完全一致,便于项目管理与代码检索。
4.本单元小结:类与对象练习
知识点
- 核心概念落地:类是抽象模板,对象是具体实例;类定义结构与行为,对象承载业务数据与状态。
- 对象生命周期:构造函数创建 → 方法调用交互 → GC 自动回收,企业开发需关注对象初始化合法性与空引用安全。
- 引用类型本质:类是引用类型,对象赋值是传递内存地址,多个变量可指向同一个实例,修改会互相影响。
- 综合能力:能根据业务需求抽象出类与类的关联关系,合理分配职责,构建可扩展的领域模型。
示例:客户订单管理综合系统
综合运用类、对象、属性、方法、构造、集合,实现客户与订单的关联管理,包含等级折扣、历史消费统计等业务逻辑。
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}");
}
}
}
语法注意事项与最佳实践
-
引用类型默认值为
null,调用任何成员前必须做空值检查,否则抛出NullReferenceException。 -
集合属性推荐在定义时直接初始化(如
List<Order> _orders = new();),避免添加元素时报空引用。 -
对象之间通过方法交互,禁止直接修改对方内部字段,保持封装边界。
封装边界:只用方法交互,不直接访问对方内部字段
原则:私有字段不对外暴露;外部只能通过公开方法 / 只读属性访问,不能直接写内部集合、私有变量
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;修改行为全部封装到类的方法;外部只能调用方法,不能直接读写对方私有成员。
- 双向关联(客户有订单,订单有客户)需注意循环引用,序列化和内存释放时要特别处理。
双向关联:
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
知识点
- 封装的本质:隐藏内部实现细节,暴露稳定的对外接口,保护对象内部状态的一致性(不变量),是面向对象三大特性之首。
- public:公开访问级别,对所有代码可见,用于定义类的对外契约(公共方法、只读属性)。
- private:私有访问级别,仅在当前类内部可见,用于隐藏字段、辅助方法、实现细节,是实现封装的主要手段。
- 最小暴露原则:能私有就私有,只对外暴露必要的操作;所有字段必须私有,禁止直接公开数据字段。
示例:银行账户类
通过封装保证账户余额的安全性,外部只能通过存款、取款、转账等公共方法操作,无法直接修改余额,杜绝非法状态。
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), "单笔交易金额超限");
}
}
}
语法注意事项与最佳实践
- 类成员默认访问级别为
private,显式声明可提升代码可读性。 - 类的默认访问级别为
internal,而非public;跨程序集访问必须显式声明public class。 - 同一个类的不同实例之间可以互相访问私有成员,这是 C# 的语言规则。
internal 的含义:仅在【当前程序集 (assembly)】内部可见
- ✅ 同一个项目(同一个 dll/exe)里,所有代码都可以访问这个 internal 类,随便实例化、继承
- ❌ 别的项目(别的程序集)引用当前 dll,看不到这个类,无法使用
- 属性可以单独为
get/set指定访问级别,如public decimal Balance { get; private set; },实现外部只读、内部可写。 - 误区警示:封装 ≠ 字段私有 + 对应属性。封装的核心是控制状态合法性,而非单纯的语法包装。
- 最佳实践 :核心业务字段使用
readonly修饰不可变部分(如账号),防止意外修改。