☰
C#反射实现对象属性复制:从原理到高性能实践
2026/10/12 21:05:09 网站建设 项目流程

1. 为什么需要对象属性复制这件事

做过C#业务开发的人,几乎都绕不开一个场景:从数据访问层拿到一个实体对象,要把它转成展示层的视图对象;或者从接口收到一个请求模型,要把它落到领域模型里。两个类的字段大部分重合,但类型不同、命名空间不同,甚至一个属性叫UserName,另一个叫Name。手写赋值当然可以,target.Name = source.UserName;一行行敲下去,十个属性还好,五十个属性就是灾难,改一个字段名要全局搜索替换,漏一处就是线上Bug。

我最早接触这个问题是在一个后台管理系统的重构里。老系统用的是三层架构,数据层实体有三十多个属性,展示层DTO也有三十多个,两边字段名有八成一样,剩下两成需要做类型转换。当时团队里有人提议用AutoMapper,但项目组对引入第三方库有严格审批流程,而且那个项目体量不大,为这一个功能引一个库有点杀鸡用牛刀。于是我就想,C#本身有反射,能不能自己写一个通用的属性复制工具,既不用引库,又能覆盖大部分场景。

反射这个东西,很多初学者觉得它“慢”“危险”“不该在业务代码里用”。这话对了一半。反射确实比直接调用慢,但在对象属性复制这个场景里,它带来的开发效率提升远远超过那点性能损耗。而且只要用对地方——比如只在对象转换的边界层用,不在高频循环里反复调用——性能完全可接受。我实测过,一个三十属性的对象,反射复制一次大概在微秒级别,对于绝大多数Web请求来说,这点开销可以忽略不计。

这篇文章就是把我这些年用反射做属性复制的经验完整梳理一遍。从最基础的PropertyInfo获取,到类型转换、空值处理、性能优化,再到实际项目里踩过的坑,都会讲到。适合有一定C#基础、正在做业务开发、被对象转换折磨过的朋友。如果你刚学C#不久,也能看懂,因为我会把每个关键点都拆开讲清楚。

2. 反射复制属性的核心思路拆解

2.1 为什么选反射而不是硬编码或表达式树

对象属性复制本质上就是“读源对象的属性值,写到目标对象的同名属性上”。硬编码就是手写赋值,优点是快、直观,缺点是维护成本高。表达式树是另一种方案,编译后性能接近硬编码,但写起来复杂,调试也麻烦。反射介于两者之间:写起来简单,性能够用,维护成本低。

我选反射的理由很直接:通用性。一个反射方法写好了,所有类都能用,新增字段不用改代码。表达式树虽然快,但每个类型对都要单独编译一次,缓存管理复杂,对于中小型项目来说,投入产出比不划算。AutoMapper这类库底层也是反射加表达式树缓存,但引入库意味着多一个依赖,版本升级、配置学习都是成本。

还有一个关键点:反射能处理运行时才知道类型的场景。比如你从消息队列收到一个JSON,反序列化成object,需要根据消息头里的类型名动态转成对应的DTO。这种场景硬编码和表达式树都做不了,只有反射能胜任。

2.2 属性匹配策略:同名匹配还是特性标记

最朴素的匹配策略是“同名属性直接复制”。源对象有Name,目标对象也有Name,类型兼容就复制。这个策略覆盖了大部分场景,但有两个问题:一是大小写敏感,name和Name在C#里是两个不同的属性;二是有些字段名确实不一样,比如源对象叫UserId,目标对象叫Id。

我的做法是:默认同名匹配,同时支持特性标记。定义一个[CopyTo("TargetName")]特性,贴在源属性上,反射时优先读特性,没有特性再走同名匹配。这样既保持了简单场景的零配置,又给特殊场景留了口子。大小写问题我建议统一用OrdinalIgnoreCase比较,因为很多数据库字段是下划线命名,转成C#属性后变成驼峰,大小写不匹配很常见。

2.3 类型转换的边界在哪里

类型转换是反射复制里最容易被低估的部分。int转long、string转int、DateTime转string,这些都需要处理。我的原则是:只做安全的、可预期的转换。比如int到long是隐式转换,直接赋值;string到int用int.TryParse,失败就跳过或给默认值;Nullable<T>到T要判断是否有值。

不做的事情也很明确:不自动做string到DateTime的复杂解析,因为格式不确定;不自动做枚举和字符串的互转,因为枚举名和显示名可能不一致。这些场景应该由业务代码显式处理,而不是藏在反射工具里。反射工具只做“搬运”,不做“翻译”。

2.4 性能优化的三个关键点

反射慢,主要慢在三个地方:GetProperties()每次调用都返回新数组、PropertyInfo.GetValue/SetValue有装箱拆箱、MethodInfo.Invoke有参数数组分配。优化手段对应也有三个:

第一,缓存PropertyInfo。用ConcurrentDictionary<Type, PropertyInfo[]>把每个类型的属性数组缓存起来,只取一次。注意要缓存GetProperties()的结果,而不是每次遍历。

第二,用委托代替直接反射调用。PropertyInfo.GetValue底层是MethodInfo.Invoke,可以用Delegate.CreateDelegate把getter和setter转成强类型委托,调用时没有反射开销。这个优化能把性能提升五到十倍。

第三,避免在循环里做类型判断。类型转换的逻辑应该在缓存构建阶段就确定好,运行时直接执行转换委托,而不是每次判断“这个属性是int还是string”。

3. 核心细节解析与实操要点

3.1 获取属性列表的正确姿势

Type.GetProperties()有几个重载,默认返回所有public实例属性,包括继承来的。但有几个坑要注意:

  • 静态属性:默认不返回,但如果你用BindingFlags.Public | BindingFlags.Static就会返回。对象复制通常不需要静态属性,所以用默认的BindingFlags.Public | BindingFlags.Instance更安全。
  • 索引器:Item属性会被返回,但它需要参数才能取值,反射复制时要过滤掉。判断方法是property.GetIndexParameters().Length > 0。
  • 只读属性:只有getter没有setter的属性,复制时应该跳过,否则SetValue会抛异常。
  • init-only属性:C# 9引入的init访问器,反射SetValue在对象初始化后调用会失败。如果目标类有init属性,要么用构造函数,要么跳过。

我通常这样写获取逻辑:

var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.CanWrite) .Where(p => p.GetIndexParameters().Length == 0) .ToArray();

这段代码看起来简单,但CanWrite对init属性返回true,实际调用会失败。所以更稳妥的做法是在缓存构建时用SetMethod判断,并且对init属性做特殊标记。

3.2 特性标记的设计与读取

自定义特性让映射更灵活。我定义的这个特性很简单:

[AttributeUsage(AttributeTargets.Property)] public class CopyToAttribute : Attribute { public string TargetName { get; } public CopyToAttribute(string targetName) => TargetName = targetName; }

读取时,先看源属性有没有CopyToAttribute,有就用指定的目标名去目标类型找属性;没有就用源属性名。这里有个细节:特性读取也要缓存。GetCustomAttribute是反射调用,每次读都有开销。我在构建映射关系时一次性把特性读出来,存到映射字典里。

还有一个进阶用法:支持忽略标记。加一个[CopyIgnore]特性,贴了就不复制。这在源对象有敏感字段(比如密码)时特别有用,避免不小心复制到DTO里返回给前端。

3.3 类型转换的完整处理链

类型转换我分四层处理:

第一层,类型相同直接赋值。这是最快路径,占大多数场景。

第二层,可空类型解包。源是int?,目标是int,先判断HasValue,有值就取Value再赋值;源是int,目标是int?,直接赋值,CLR会自动包装。

第三层,隐式数值转换。int到long、float到double这些,用Convert.ChangeType处理。但要注意Convert.ChangeType对Nullable<T>支持不好,需要先解包。

第四层,字符串与基础类型互转。string到int用int.TryParse,int到string用ToString()。DateTime和string的转换我不自动做,因为格式不确定,留给业务层。

枚举的处理单独说:如果源和目标都是同一个枚举类型,直接赋值;如果源是字符串目标是枚举,用Enum.TryParse;如果源是枚举目标是字符串,用ToString()。不同枚举类型之间不自动转,因为值可能不对应。

3.4 空值处理的策略选择

空值处理有三种策略:跳过、赋默认值、抛异常。我的默认策略是“跳过”——源属性为null时,不修改目标属性。这样做的理由是:目标对象可能已经有值,null覆盖会丢失信息。比如更新场景,前端只传了要改的字段,null表示“不修改”,而不是“改成null”。

但有些场景需要“null也复制”,比如全量替换。所以我加了一个CopyOptions参数,支持配置IgnoreNull。默认true,需要全量复制时设为false。

还有一个细节:值类型不能为null。源是int,永远是0或具体值,不存在null。但如果源是object类型装了个int,拆箱时要注意。这种情况我建议在映射构建阶段就检查类型兼容性,不兼容的直接跳过,而不是运行时抛异常。

4. 完整实操过程与核心代码实现

4.1 基础版本:三十行代码搞定同名复制

先看最简版本,理解核心逻辑:

public static void CopyProperties(object source, object target) { if (source == null || target == null) return; var sourceType = source.GetType(); var targetType = target.GetType(); var sourceProps = sourceType.GetProperties(BindingFlags.Public | BindingFlags.Instance); var targetProps = targetType.GetProperties(BindingFlags.Public | BindingFlags.Instance) .ToDictionary(p => p.Name, p => p, StringComparer.OrdinalIgnoreCase); foreach (var sourceProp in sourceProps) { if (!sourceProp.CanRead) continue; if (sourceProp.GetIndexParameters().Length > 0) continue; if (!targetProps.TryGetValue(sourceProp.Name, out var targetProp)) continue; if (!targetProp.CanWrite) continue; var value = sourceProp.GetValue(source); if (value == null) continue; if (targetProp.PropertyType.IsAssignableFrom(sourceProp.PropertyType)) { targetProp.SetValue(target, value); } } }

这段代码能跑,但问题很多:每次调用都重新获取属性、没有类型转换、没有缓存。不过它展示了核心流程:获取源属性、匹配目标属性、读值、写值。理解了这个,后面的优化都是围绕“减少重复计算”和“扩展转换能力”展开的。

4.2 进阶版本:带缓存和类型转换的实现

实际项目里我用的是这个结构:

public static class ObjectCopier { private static readonly ConcurrentDictionary<(Type, Type), List<PropertyMap>> _mapCache = new(); private class PropertyMap { public PropertyInfo Source { get; set; } public PropertyInfo Target { get; set; } public Func<object, object> Getter { get; set; } public Action<object, object> Setter { get; set; } public Func<object, object> Converter { get; set; } } public static void Copy<TSource, TTarget>(TSource source, TTarget target, CopyOptions options = null) where TSource : class where TTarget : class { if (source == null || target == null) return; options ??= CopyOptions.Default; var key = (typeof(TSource), typeof(TTarget)); var maps = _mapCache.GetOrAdd(key, k => BuildMaps(k.Item1, k.Item2)); foreach (var map in maps) { var value = map.Getter(source); if (value == null && options.IgnoreNull) continue; var converted = map.Converter != null ? map.Converter(value) : value; if (converted == null && options.IgnoreNull) continue; map.Setter(target, converted); } } }

BuildMaps方法负责构建映射关系,包括匹配属性、读取特性、创建getter/setter委托、确定转换器。这个方法只在第一次遇到某个类型对时调用,之后都走缓存。

4.3 委托创建:把反射调用变成直接调用

PropertyInfo.GetValue和SetValue底层是反射调用,性能一般。用Delegate.CreateDelegate可以创建强类型委托:

private static Func<object, object> CreateGetter(PropertyInfo prop) { var getMethod = prop.GetGetMethod(); if (getMethod == null) return null; var funcType = typeof(Func<,>).MakeGenericType(prop.DeclaringType, prop.PropertyType); var del = Delegate.CreateDelegate(funcType, getMethod); return obj => del.DynamicInvoke(obj); }

但DynamicInvoke还是有开销。更好的做法是用表达式树编译:

private static Func<object, object> CreateGetter(PropertyInfo prop) { var objParam = Expression.Parameter(typeof(object), "obj"); var castObj = Expression.Convert(objParam, prop.DeclaringType); var property = Expression.Property(castObj, prop); var box = Expression.Convert(property, typeof(object)); return Expression.Lambda<Func<object, object>>(box, objParam).Compile(); }

表达式树编译后的委托,调用性能接近直接属性访问。setter同理。这个优化在批量复制场景下效果明显,我实测过,一万次复制从800毫秒降到80毫秒左右。

4.4 类型转换器的实现细节

转换器在构建映射时确定,运行时直接调用。核心逻辑:

private static Func<object, object> BuildConverter(Type sourceType, Type targetType) { if (targetType.IsAssignableFrom(sourceType)) return null; // 不需要转换 var underlyingSource = Nullable.GetUnderlyingType(sourceType) ?? sourceType; var underlyingTarget = Nullable.GetUnderlyingType(targetType) ?? targetType; if (underlyingTarget == typeof(string)) return v => v?.ToString(); if (underlyingSource == typeof(string)) { if (underlyingTarget == typeof(int)) return v => int.TryParse(v?.ToString(), out var r) ? r : (object)null; if (underlyingTarget == typeof(long)) return v => long.TryParse(v?.ToString(), out var r) ? r : (object)null; if (underlyingTarget == typeof(decimal)) return v => decimal.TryParse(v?.ToString(), out var r) ? r : (object)null; if (underlyingTarget == typeof(double)) return v => double.TryParse(v?.ToString(), out var r) ? r : (object)null; if (underlyingTarget == typeof(bool)) return v => bool.TryParse(v?.ToString(), out var r) ? r : (object)null; } if (underlyingSource.IsEnum && underlyingTarget == typeof(string)) return v => v?.ToString(); if (underlyingSource == typeof(string) && underlyingTarget.IsEnum) return v => Enum.TryParse(underlyingTarget, v?.ToString(), true, out var r) ? r : (object)null; if (IsNumeric(underlyingSource) && IsNumeric(underlyingTarget)) return v => Convert.ChangeType(v, underlyingTarget); return null; // 不支持的转换,跳过 }

这段代码覆盖了日常开发中九成以上的转换需求。注意Nullable<T>的处理:Nullable.GetUnderlyingType拿到基础类型,转换后再由CLR自动包装成可空类型。

4.5 实际调用示例与效果验证

假设有两个类:

public class UserEntity { public int Id { get; set; } public string UserName { get; set; } public string Email { get; set; } public DateTime CreatedAt { get; set; } public int? Age { get; set; } } public class UserDto { public int Id { get; set; } [CopyTo("UserName")] public string Name { get; set; } public string Email { get; set; } public string CreatedAt { get; set; } public int Age { get; set; } }

调用:

var entity = new UserEntity { Id = 1, UserName = "testuser", Email = "test@example.com", CreatedAt = DateTime.Now, Age = 25 }; var dto = new UserDto(); ObjectCopier.Copy(entity, dto);

结果:dto.Id为1,dto.Name为"testuser"(通过特性映射),dto.Email复制成功,dto.CreatedAt是字符串格式(DateTime转string),dto.Age为25(可空int转int)。

验证时我建议写单元测试,覆盖:同名复制、特性映射、类型转换、null跳过、只读属性跳过、索引器跳过。每个场景一个测试用例,改代码时跑一遍,心里有底。

5. 常见问题与排查技巧实录

5.1 属性复制不生效的排查清单

实际用的时候,最常遇到的问题是“为什么这个属性没复制过去”。我整理了一个排查顺序:

现象可能原因排查方法
所有属性都没复制源或目标为null加断点看参数
部分属性没复制属性名不匹配检查大小写、特性标记
部分属性没复制目标属性只读看有没有setter
部分属性没复制类型不兼容看转换器是否返回null
值复制了但不对转换逻辑错误单独测转换器
抛异常init属性改用构造函数或跳过

我踩过最坑的一次是:目标类的属性是private set,CanWrite返回true,但SetValue抛异常。后来改成检查SetMethod.IsPublic才解决。还有一次是源属性是virtual,子类重写了,反射拿到的是基类的PropertyInfo,取值时走的是子类实现,这个倒没问题,但调试时容易看晕。

5.2 性能问题的定位与优化

如果发现复制慢,先确认是不是在循环里反复调用。我见过有人在for循环里对每个元素调Copy,每次Copy都重新GetProperties,一万个元素就是一万次反射。解决办法很简单:把Copy提到循环外,或者用缓存版本。

另一个性能陷阱是装箱拆箱。GetValue返回object,值类型属性每次取值都装箱。优化方法是表达式树编译,直接返回强类型值再装箱一次,比反射调用少一次装箱。对于值类型多的类,这个优化效果明显。

还有一个容易忽略的点:字符串拼接。有些转换器里用v.ToString(),如果v是复杂对象,ToString()可能很慢。建议在转换器里明确类型,不要依赖默认ToString()。

5.3 循环引用与深度复制

如果两个对象互相引用,比如Order有Customer,Customer有Order列表,反射复制会无限递归。我的做法是:默认只复制浅层属性,遇到复杂对象类型直接跳过,不递归。需要深度复制时,由业务代码显式处理,或者用专门的序列化方案。

判断“复杂对象”的标准:不是基元类型(int、string、bool等)、不是枚举、不是DateTime、不是decimal,就认为是复杂对象,跳过。这个策略简单有效,避免了递归陷阱。

5.4 线程安全问题

ConcurrentDictionary本身是线程安全的,但BuildMaps可能被多个线程同时调用。我用GetOrAdd的重载版本,传入工厂委托,确保只构建一次。但要注意:工厂委托可能被执行多次(ConcurrentDictionary不保证只执行一次),所以BuildMaps必须是幂等的,不能有副作用。

还有一个细节:表达式树编译是线程安全的,但编译本身有开销。如果类型对很多,首次调用会慢。可以在应用启动时预热,把常用类型对提前构建好。

5.5 与序列化方案的对比选择

有时候反射复制不是最优解。如果两个对象结构完全一样,用JSON序列化再反序列化也能实现复制,代码更少。但序列化有性能开销,而且会丢失类型信息(比如DateTime变成字符串再变回来)。我的选择标准是:

  • 结构简单、类型一致:反射复制
  • 需要跨进程、跨语言:序列化
  • 需要深度复制:序列化或专门的克隆方法
  • 性能敏感:表达式树或手写赋值

没有银弹,看场景选工具。

6. 几个我踩过的坑和实用建议

第一个坑是枚举转换。源是int,目标是枚举,我一开始用Convert.ChangeType,结果抛异常。后来改成先转int再Enum.ToObject,才搞定。枚举和字符串的转换也要注意大小写,Enum.TryParse默认区分大小写,加true参数才忽略。

第二个坑是日期格式。DateTime转string时,ToString()用当前区域设置,不同服务器可能不一样。我后来统一用ToString("yyyy-MM-dd HH:mm:ss"),避免格式混乱。反过来,string转DateTime我不自动做,因为格式太多,让业务层显式处理更安全。

第三个坑是继承属性。GetProperties默认返回继承链上的所有public属性,但如果子类用new隐藏了基类属性,会返回两个同名属性,ToDictionary直接抛异常。解决办法是用BindingFlags.DeclaredOnly只取当前类声明的,或者用GroupBy去重取第一个。

第四个坑是泛型类型。List<int>和List<string>是不同的类型,IsAssignableFrom返回false。如果源和目标都是List<T>但T不同,需要特殊处理。我的做法是:集合类型不自动转换,跳过,由业务层处理。

实用建议方面,我强烈建议写单元测试。反射代码容易出边界问题,测试覆盖到了,改起来才放心。另外,日志要打全。复制失败时,把源类型、目标类型、属性名、失败原因都记下来,排查效率翻倍。最后,不要过度设计。如果项目里只有两三个类需要复制,手写赋值可能比反射更省事。反射工具适合“有很多类需要复制”或者“类型在运行时才知道”的场景。

这个工具我用了好几年,从最初三十行的简陋版本,到现在带缓存、带转换、带特性的完整版,中间迭代了十几次。每次遇到新场景就加一点,慢慢就覆盖了大部分需求。如果你也在做类似的事,建议从简单版本开始,遇到问题再优化,不要一上来就追求完美。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询