从 Java 转到 C# 做上位机开发会发现语法长得像但很多机制的味道不一样。这篇博客把五个绕不开的主题串起来泛型、文件操作、委托、事件、线程。它们看似独立实际上层层递进——委托是事件的基础事件是线程间通信的常用手段而泛型几乎渗透在所有 API 里。一、泛型Generics1.1 为什么需要泛型没有泛型之前想写一个通用容器只能用object取值时要强制类型转换既慢装箱拆箱又不安全运行时才报错。// 没有泛型不安全、有装箱开销 ArrayList list new ArrayList(); list.Add(1); // int 装箱成 object list.Add(hello); // 类型不一致编译器不会报错 int x (int)list[1]; // 运行时抛异常泛型把类型检查提前到编译期Listint list new Listint(); list.Add(1); // list.Add(hello); // 编译期直接报错 int x list[0]; // 无需强转无装箱这一点和 Java 泛型的动机是一致的。区别在于实现方式——这是 C# 和 Java 泛型最大的分歧点。1.2 C# 泛型 vs Java 泛型真泛型 vs 类型擦除Java 的泛型是编译期的语法糖运行时会做类型擦除type erasureListString和ListInteger在运行时其实是同一个List类。C# 的泛型是具体化的Reified GenericsCLR 在运行时会为值类型的泛型参数生成专门的机器码版本。Listint和Liststring在运行时是两个不同的类型int不会被装箱。这带来两个好处值类型泛型没有装箱拆箱开销性能更好可以用typeof(T)、反射拿到真实的类型参数信息。public class BoxT { private T _value; public void Set(T value) _value value; public T Get() _value; public void PrintType() { Console.WriteLine(typeof(T).Name); // Java 里做不到这么直接 } }1.3 泛型约束ConstraintsC# 用where子句限制类型参数的范围这也是和 Javaextends边界写法不同的地方public class RepositoryT where T : class, IEntity, new() { public T CreateNew() new T(); // 有 new() 约束才能这样写 } public interface IEntity { int Id { get; set; } }常见约束速查约束含义where T : structT 必须是值类型where T : classT 必须是引用类型where T : new()T 必须有无参构造函数where T : BaseClassT 必须继承自 BaseClasswhere T : IInterfaceT 必须实现某接口1.4 协变与逆变Covariance / Contravariance这是 C# 泛型里比较绕但面试常问的点用out/in关键字标注// 协变out —— 只能作为返回值输出 public interface IProducerout T { T Produce(); } // 逆变in —— 只能作为参数输入 public interface IConsumerin T { void Consume(T item); } IProducerstring stringProducer new StringProducer(); IProducerobject objectProducer stringProducer; // 协变string 是 object 的子类可以向上转 IConsumerobject objectConsumer new ObjectConsumer(); IConsumerstring stringConsumer objectConsumer; // 逆变反过来接受更宽泛类型的消费者可以当作窄类型用记忆技巧生产者向上转out消费者向下用in。IEnumerableout T、Actionin T、Funcout TResult都是内置的协变/逆变接口。二、文件操作System.IO上位机项目经常要读写配置文件、日志、采集数据System.IO是绕不开的命名空间。2.1 核心类分工类用途File静态类一次性读写整个文件FileInfo实例类需要多次操作同一文件时更高效Directory/DirectoryInfo目录操作StreamReader/StreamWriter按行/按流读写文本FileStream底层字节流读写二进制Path路径拼接、扩展名处理跨平台安全2.2 最常用的简单读写// 一次性写入/读取全部文本 File.WriteAllText(config.txt, 波特率9600); string content File.ReadAllText(config.txt); // 按行读取适合日志类文件 string[] lines File.ReadAllLines(log.txt); foreach (var line in lines) { Console.WriteLine(line); } // 追加写入日志场景常用 File.AppendAllText(log.txt, ${DateTime.Now:HH:mm:ss} 设备已连接\n);2.3 流式读写大文件/持续采集场景一次性读取适合小文件但如果是持续写入的日志或大数据量采集应该用流并配合using保证资源释放using (StreamWriter writer new StreamWriter(data.csv, append: true)) { writer.WriteLine(${DateTime.Now},{temperature},{pressure}); } // 自动 Flush Dispose不用手动 Close using (StreamReader reader new StreamReader(data.csv)) { string line; while ((line reader.ReadLine()) ! null) { ProcessLine(line); } }C# 8 之后还可以用简化的using声明不用嵌套大括号using var writer new StreamWriter(data.csv, append: true); writer.WriteLine(hello); // 离开作用域时自动释放2.4Path类别自己拼字符串新手容易手写folder \\ fileName在跨平台或路径分隔符处理上容易出错应该用Pathstring fullPath Path.Combine(baseDir, config, settings.json); string ext Path.GetExtension(fullPath); // .json string nameOnly Path.GetFileNameWithoutExtension(fullPath);三、委托Delegate3.1 委托是什么委托本质是一个类型安全的函数指针——它描述一个方法长什么样签名可以把符合这个签名的方法当作变量传来传去。这是理解事件、Lambda、Action/Func的地基。// 声明一个委托类型约定了参数和返回值的形状 public delegate void MessageHandler(string message); public class Logger { public void LogToConsole(string msg) Console.WriteLine($[控制台] {msg}); public void LogToFile(string msg) File.AppendAllText(log.txt, msg); } // 使用 Logger logger new Logger(); MessageHandler handler logger.LogToConsole; handler logger.LogToFile; // 多播委托一次调用触发多个方法 handler(设备连接成功);这个动作在 Java 里没有直接对应物Java 得靠观察者模式手动维护 ListListener委托把这个多播能力内建在语言层面。3.2 内置通用委托不用每次都自定义大多数场景不需要自己声明delegateBCL 已经提供了Actionstring print msg Console.WriteLine(msg); // 无返回值 Funcint, int, int add (a, b) a b; // 有返回值最后一个类型参数是返回类型 Predicateint isEven n n % 2 0; // 专门返回 bool print(hello); int sum add(3, 4); bool result isEven(10);上位机场景常见用法把耗时操作和操作完成后干什么解耦。public void ReadSensorAsync(Actiondouble onDataReceived) { // 模拟异步读取传感器数据 double value 25.6; onDataReceived(value); } ReadSensorAsync(temp Console.WriteLine($温度{temp}°C));四、事件Event4.1 事件是受限的委托事件建立在委托之上但加了访问限制外部只能/-订阅不能直接赋值覆盖别人的订阅也不能从外部主动触发。这是发布-订阅模式在语言层面的直接支持。public class TemperatureSensor { // 先定义委托类型或直接用内置的 EventHandler public event EventHandlerdouble TemperatureChanged; private double _temperature; public double Temperature { get _temperature; set { _temperature value; // 只有类内部能触发事件 TemperatureChanged?.Invoke(this, value); } } }订阅方var sensor new TemperatureSensor(); sensor.TemperatureChanged (sender, temp) { Console.WriteLine($温度更新为{temp}°C); if (temp 80) { Console.WriteLine(警告温度过高); } }; sensor.Temperature 85; // 触发事件4.2 标准事件模式EventHandler.NET 约定的标准写法是用EventHandler或EventHandlerTEventArgs自定义事件参数时继承EventArgspublic class DeviceStatusEventArgs : EventArgs { public string DeviceName { get; } public bool IsOnline { get; } public DeviceStatusEventArgs(string deviceName, bool isOnline) { DeviceName deviceName; IsOnline isOnline; } } public class Device { public event EventHandlerDeviceStatusEventArgs StatusChanged; protected virtual void OnStatusChanged(DeviceStatusEventArgs e) { StatusChanged?.Invoke(this, e); } public void Connect() { // 连接逻辑... OnStatusChanged(new DeviceStatusEventArgs(PLC-01, true)); } }?.Invoke这个写法要记牢如果没有任何订阅者StatusChanged是null直接调用会抛NullReferenceException用?.做空条件判断是标准防御写法。4.3 WinForms/WPF 里为什么到处是事件上位机开发大量依赖控件事件Button.Click、SerialPort.DataReceived等本质就是上面这套模式。理解了委托和事件就能看懂button1.Click Button1_Click; // 订阅 private void Button1_Click(object sender, EventArgs e) { MessageBox.Show(按钮被点击); }五、线程Threading5.1 为什么上位机离不开多线程工业上位机的典型痛点串口/网络数据采集是持续的、耗时的如果放在 UI 主线程里界面会卡死。所以耗时操作丢到后台线程结果再更新回 UI是最基础的需求。5.2 最基础Threadusing System.Threading; Thread t new Thread(() { for (int i 0; i 5; i) { Console.WriteLine($后台线程{i}); Thread.Sleep(1000); } }); t.IsBackground true; // 设为后台线程主程序退出时它也退出 t.Start();Thread是最底层的方式现在生产代码里很少直接用更多是用线程池或Task。5.3 线程池Task推荐方式using System.Threading.Tasks; Task.Run(() { // 耗时操作比如读串口 var data ReadFromSerialPort(); Console.WriteLine(data); });Task配合async/await是现代 C# 的主流写法public async Taskstring ReadSensorDataAsync() { return await Task.Run(() { Thread.Sleep(2000); // 模拟耗时 IO return 温度: 26.3°C; }); } // 调用方 string result await ReadSensorDataAsync(); Console.WriteLine(result);5.4 跨线程更新 UI上位机最常踩的坑WinForms/WPF 的 UI 控件不是线程安全的后台线程不能直接改控件属性否则会抛异常或产生不可预期的界面问题。WinForms 写法用Invoke/BeginInvokeprivate void UpdateLabel(string text) { if (label1.InvokeRequired) { label1.Invoke(new Action(() label1.Text text)); } else { label1.Text text; } }WPF 写法用DispatcherApplication.Current.Dispatcher.Invoke(() { label1.Content 更新后的文本; });这也是为什么委托要先学Invoke(new Action(...))传进去的就是一个委托实例。5.5 线程安全lock与共享资源多个线程同时读写同一个变量比如采集队列时需要加锁防止数据竞争private readonly object _lockObj new object(); private Queuedouble _dataQueue new Queuedouble(); public void AddData(double value) { lock (_lockObj) { _dataQueue.Enqueue(value); } } public double? TakeData() { lock (_lockObj) { return _dataQueue.Count 0 ? _dataQueue.Dequeue() : (double?)null; } }如果只是简单的生产者-消费者场景System.Collections.Concurrent命名空间下的ConcurrentQueueT更省心内部已经做好了线程安全using System.Collections.Concurrent; ConcurrentQueuedouble queue new ConcurrentQueuedouble(); queue.Enqueue(25.6); if (queue.TryDequeue(out double value)) { Console.WriteLine(value); }六、五个概念是怎么串起来的用一个简化的串口采集 界面显示场景收尾正好把五个主题全用上public class SerialCollector { // 事件数据到达时通知外部基于委托 public event EventHandlerdouble DataReceived; // 泛型通用的线程安全队列缓存采集数据 private readonly ConcurrentQueuedouble _buffer new ConcurrentQueuedouble(); public void Start() { // 线程后台持续采集不阻塞 UI Task.Run(() { while (true) { double value ReadFromPort(); _buffer.Enqueue(value); DataReceived?.Invoke(this, value); // 文件顺手落盘一份原始数据 File.AppendAllText(raw_data.csv, ${DateTime.Now:O},{value}\n); Thread.Sleep(500); } }); } private double ReadFromPort() new Random().NextDouble() * 100; } // 使用方比如窗体代码里 var collector new SerialCollector(); collector.DataReceived (sender, value) { // 跨线程更新 UI Application.Current.Dispatcher.Invoke(() { label1.Content $当前值{value:F2}; }); }; collector.Start();从 Java 转过来的话可以这样对应记忆Java 概念C# 对应备注泛型类型擦除泛型具体化C# 运行时保留真实类型无装箱开销手写 Listener 接口 List 维护委托 事件语言内建多播、订阅/取消订阅Runnable/CallableAction/Func内置通用委托synchronizedlock语义类似锁对象需自己声明ExecutorService/ 线程池Task/ 线程池Task.Run更接近日常用法SwingUtilities.invokeLaterDispatcher.Invoke/Control.Invoke跨线程更新 UI 的标准做法这五个主题看着分散实际上是数据从哪来文件/线程采集→ 谁处理泛型容器→ 怎么通知委托/事件→ 怎么安全地跑线程与锁这一条完整链路。把这条链路走通一遍比孤立背语法要扎实得多。