资讯动态

C#反射实现对象属性复制:从原理到高性能实践

发布时间:2026/10/14 9:08:23 来源:尧图企业网站定制
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失败就跳过或给默认值NullableT到T要判断是否有值。不做的事情也很明确不自动做string到DateTime的复杂解析因为格式不确定不自动做枚举和字符串的互转因为枚举名和显示名可能不一致。这些场景应该由业务代码显式处理而不是藏在反射工具里。反射工具只做“搬运”不做“翻译”。2.4 性能优化的三个关键点反射慢主要慢在三个地方GetProperties()每次调用都返回新数组、PropertyInfo.GetValue/SetValue有装箱拆箱、MethodInfo.Invoke有参数数组分配。优化手段对应也有三个第一缓存PropertyInfo。用ConcurrentDictionaryType, 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对NullableT支持不好需要先解包。第四层字符串与基础类型互转。string到int用int.TryParseint到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), ListPropertyMap _mapCache new(); private class PropertyMap { public PropertyInfo Source { get; set; } public PropertyInfo Target { get; set; } public Funcobject, object Getter { get; set; } public Actionobject, object Setter { get; set; } public Funcobject, object Converter { get; set; } } public static void CopyTSource, 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 Funcobject, 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 Funcobject, 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.LambdaFuncobject, object(box, objParam).Compile(); }表达式树编译后的委托调用性能接近直接属性访问。setter同理。这个优化在批量复制场景下效果明显我实测过一万次复制从800毫秒降到80毫秒左右。4.4 类型转换器的实现细节转换器在构建映射时确定运行时直接调用。核心逻辑private static Funcobject, 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; // 不支持的转换跳过 }这段代码覆盖了日常开发中九成以上的转换需求。注意NullableT的处理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 testexample.com, CreatedAt DateTime.Now, Age 25 }; var dto new UserDto(); ObjectCopier.Copy(entity, dto);结果dto.Id为1dto.Name为testuser通过特性映射dto.Email复制成功dto.CreatedAt是字符串格式DateTime转stringdto.Age为25可空int转int。验证时我建议写单元测试覆盖同名复制、特性映射、类型转换、null跳过、只读属性跳过、索引器跳过。每个场景一个测试用例改代码时跑一遍心里有底。5. 常见问题与排查技巧实录5.1 属性复制不生效的排查清单实际用的时候最常遇到的问题是“为什么这个属性没复制过去”。我整理了一个排查顺序现象可能原因排查方法所有属性都没复制源或目标为null加断点看参数部分属性没复制属性名不匹配检查大小写、特性标记部分属性没复制目标属性只读看有没有setter部分属性没复制类型不兼容看转换器是否返回null值复制了但不对转换逻辑错误单独测转换器抛异常init属性改用构造函数或跳过我踩过最坑的一次是目标类的属性是private setCanWrite返回true但SetValue抛异常。后来改成检查SetMethod.IsPublic才解决。还有一次是源属性是virtual子类重写了反射拿到的是基类的PropertyInfo取值时走的是子类实现这个倒没问题但调试时容易看晕。5.2 性能问题的定位与优化如果发现复制慢先确认是不是在循环里反复调用。我见过有人在for循环里对每个元素调Copy每次Copy都重新GetProperties一万个元素就是一万次反射。解决办法很简单把Copy提到循环外或者用缓存版本。另一个性能陷阱是装箱拆箱。GetValue返回object值类型属性每次取值都装箱。优化方法是表达式树编译直接返回强类型值再装箱一次比反射调用少一次装箱。对于值类型多的类这个优化效果明显。还有一个容易忽略的点字符串拼接。有些转换器里用v.ToString()如果v是复杂对象ToString()可能很慢。建议在转换器里明确类型不要依赖默认ToString()。5.3 循环引用与深度复制如果两个对象互相引用比如Order有CustomerCustomer有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去重取第一个。第四个坑是泛型类型。Listint和Liststring是不同的类型IsAssignableFrom返回false。如果源和目标都是ListT但T不同需要特殊处理。我的做法是集合类型不自动转换跳过由业务层处理。实用建议方面我强烈建议写单元测试。反射代码容易出边界问题测试覆盖到了改起来才放心。另外日志要打全。复制失败时把源类型、目标类型、属性名、失败原因都记下来排查效率翻倍。最后不要过度设计。如果项目里只有两三个类需要复制手写赋值可能比反射更省事。反射工具适合“有很多类需要复制”或者“类型在运行时才知道”的场景。这个工具我用了好几年从最初三十行的简陋版本到现在带缓存、带转换、带特性的完整版中间迭代了十几次。每次遇到新场景就加一点慢慢就覆盖了大部分需求。如果你也在做类似的事建议从简单版本开始遇到问题再优化不要一上来就追求完美。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑