C#特性(Attribute)本质、自定义与反射读取实战指南

发布时间:2026/9/28 15:10:39
C#特性(Attribute)本质、自定义与反射读取实战指南 最近被好几个朋友问起C#特性Attribute到底是个什么东西有人把它和“属性Property”搞混有人说自己贴了[Serializable]程序却还是不能序列化还有人想在Unity里用特性做自定义玩法结果反射那一步直接卡住。C#里特性和委托、事件一样属于“看着简单、用起来全是细节”的机制。我这篇文章从一个老开发的角度把特性的本质、内置用法、自定义方式、反射读取和实际项目里的坑一次讲完希望大家认真看。1. 特性到底是什么从语法到本质1.1 特性的本质是一个类特性在C#里的英文是Attribute本质就是一个继承自System.Attribute的普通类。你写[Obsolete]、[Serializable]的时候编译器做的是把这段声明信息写进程序集的元数据表里。等到程序运行起来反射机制再根据元数据里的信息把对应的特性对象“构造”出来供你读取。这个理解非常关键特性不是在你贴标签的时候执行而是在你用反射去取的时候才真正实例化。你写[Obsolete(请改用新方法)] public void OldMethod() { }这段代码编译后方法还是那个方法只是在元数据里多了一条记录告诉CLR这里有个ObsoleteAttribute参数是“请改用新方法”。如果没有任何代码去调用GetCustomAttribute这个特性对象永远都不会被创建。有人刚学C#时写了一个自定义特性贴上之后发现什么效果都没有原因就在这一步特性只是元数据不是功能本身。1.2 特性与普通类、接口的区别普通类要靠new来创建实例接口要靠类来实现特性类虽然也是个类但你不能直接new到变量里用。正常写代码时你只会见到特性以[Something]的形式挂在类、方法、属性、字段、参数等目标上。从使用方式上区分对比项普通类接口特性定义方式classinterface继承System.Attribute使用位置new创建class MyClass : IMyInterface[MyAttribute]挂在声明上是否写入元数据否元数据中有类型信息但无实例声明有专用元数据记录触发时机明确调用构造器运行时类型加载反射读取时才构造我实际写代码的感觉是普通类描述“一个对象是什么”接口描述“一类能力是什么”特性描述“这段代码的注解是什么”。注解本身不干活干活的是读取注解的那段逻辑。1.3 元数据是特性发挥作用的载体C#编译后的程序集文件里有一个专门的元数据表来记录自定义特性包括特性类型、构造器参数、命名参数、目标对象标识。这就解释了为什么特性的参数必须是编译期常量因为元数据表要在编译期就把这些值固化下来你不可能把一个运行时的变量值写进程序集里。如果你以前看过ildasm或者DotPeek反编译工具输出的内容会看到每条特性对应一行.custom指令。这就是模板生成代码和普通代码的差异区间。理解这一点后后面讨论“为什么特性参数有那么多限制”就顺理成章了。2. 内置特性实战常用特性逐个拆解2.1 编译器专用Conditional、Obsolete、CallerMemberName[Obsolete]应该是C#学习路上最先遇到的特性之一。它有两个核心参数第一个参数是一个字符串说明过时原因第二个参数是booltrue表示直接编译报错false表示只给警告。[Obsolete(请使用NewMethod, false)] public void OldMethod() { }还有一类特性是影响编译器的[Conditional(DEBUG)]就是典型代表。被它标记的方法如果预处理器符号DEBUG没定义编译器会直接忽略方法调用语句连代码都不会生成。这类特性严格来说已经超出“纯元数据”的范畴它干预的是编译过程本身。CallerMemberName这类特性长得不太一样它一般用在方法参数上编译器会自动把调用方的成员名填进去。以前做INotifyPropertyChanged必须手动写字符串后来用这个特性就不用了protected void OnPropertyChanged([CallerMemberName] string propertyName ) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }2.2 运行时专用Serializable、DllImport、StructLayout[Serializable]用来标记类可以被二进制序列化最常见的问题是有人标记完就以为序列化一定成功结果类里有流对象、数据库连接这类不可序列化字段照样报异常。特性在这里只是一个“许可”具体能不能序列化成功还要看对象图。[DllImport]是P/Invoke的入口用来声明非托管DLL中的函数。做上位机或者其他需要调用原生库的时候经常见到[DllImport(user32.dll, CharSet CharSet.Auto)] public static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);[StructLayout]则能控制结构体在内存里的排列方式。LayoutKind.Sequential保证字段按声明顺序排列LayoutKind.Explicit配合FieldOffset可以实现C/C里的联合体效果。这部分涉及到内存布局的知识平常写托管代码很少碰但一旦要跟C交互或者做高性能网络协议解析时它就是救命的工具。2.3 特性不是“自动生效”的关键在解读器很多新手最大的误区是以为贴上特性运行时框架就会自动识别。实际上框架干的只是在内部调用反射去读取这些特性。比如ASP.NET Core里[HttpGet]能起作用是因为路由系统在启动时扫描控制器方法上的特性序列化器能识别[JsonProperty]是因为它在反射枚举属性时主动查了这个特性。所以“特性是否生效”取决于有没有一个“解读器”去读它。自己写自定义特性时这个解读器就要自己实现。想清楚这一点后面写自定义特性心里就踏实了。3. 手写自定义特性从零构建一套完整方案3.1 自定义特性需要什么创建自定义特性很简单继承Attribute按需写构造器和属性再加一个[AttributeUsage]就行。下面是我在项目里常用的列映射特性[AttributeUsage(AttributeTargets.Property, AllowMultiple false, Inherited true)] public sealed class ColumnAttribute : Attribute { public string ColumnName { get; } public int Order { get; set; } public string Description { get; set; } public ColumnAttribute(string columnName) { ColumnName columnName; } }有一点要特别注意自定义特性类的命名习惯是“后缀加Attribute”用的时候可以不写这个后缀。你定义ColumnAttribute代码里写[Column(user_id)]即可。但如果类名本身不带Attribute后缀就必须写全名。3.2 AttributeUsage的三个参数怎么定AttributeUsage控制特性的适用范围它有三个属性AttributeTargets是必填参数用枚举组合指定目标位置常见的有Class、Method、Property、Field、Parameter、Enum、Assembly等。多个目标用按位或组合[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple true)]AllowMultiple决定同一目标上能否多次标注同一个特性。比如要做多个标签映射就需要AllowMultiple true像[Serializable]这种“只有一个状态”的特性就设为false更合理。Inherited决定特性能否被继承。如果设为true父类上的特性在通过子类反射读取时也能拿到。这个参数看着简单实际和AllowMultiple组合起来会出现一些让人意外的行为后面第四节会展开讲。3.3 定位参数与命名参数的区别自定义特性可以同时支持定位参数和命名参数。定位参数对应构造器参数必须按构造器签名顺序传值命名参数对应可读写的属性或字段通过属性名 值的形式传可以省略。[Column(user_id, Order 1, Description 用户ID)] public int UserId { get; set; } [Column(nickname, Order 2)] public string NickName { get; set; }我设计特性时的原则是必填信息放定位参数可选信息放命名参数。比如列名是必需的就放进构造器顺序和描述是附加信息就做成命名属性。这样调用点干净语义也清楚。4. 反射读取与动态应用特性如何发挥作用4.1 用GetCustomAttribute读取特性反射读取特性其实是比较标准的套路。以属性为例PropertyInfo prop typeof(UserDto).GetProperty(UserId); ColumnAttribute attr prop?.GetCustomAttributeColumnAttribute();如果你用的是.NET Framework记得加System.Reflection的引用。读取方法、类、枚举字段上的特性也差不多都是先拿到对应的MemberInfo再调用扩展方法。还有一个方法重载值得注意GetCustomAttributeT(MemberInfo, bool inherit)。第二个参数inherit控制是否沿继承链向上查找。我在项目里发现很多人不重视这个参数结果子类的属性莫名其妙读到了基类的特性排查半天。4.2 枚举扩展与下拉框显示的实战特性最经典的低门槛应用就是给枚举加描述文字前端下拉框、日志文本、导出Excel表头都靠它。先定义一个描述特性[AttributeUsage(AttributeTargets.Field)] public sealed class DescAttribute : Attribute { public string Text { get; } public DescAttribute(string text) Text text; }然后给枚举值贴标签public enum DeviceStatus { [Desc(离线)] Offline 0, [Desc(运行中)] Running 1, [Desc(告警)] Alarm 2 }再写一个扩展方法读取public static string ToDesc(this Enum value) { var field value.GetType().GetField(value.ToString()); var attr field?.GetCustomAttributeDescAttribute(); return attr?.Text ?? value.ToString(); }这个方法的妙处在于业务代码里到处都能用DeviceStatus.Running.ToDesc()这种写法字符串散落的问题彻底消除了。后续要加设备状态只需要改枚举定义所有显示文本同步更新。4.3 数据校验框架做上位机或者Web项目时参数校验是个烦心事。用特性可以做一个简单的声明式校验框架。定义一个基类特性public abstract class ValidationAttribute : Attribute { public string ErrorMessage { get; set; } public abstract bool IsValid(object value); } [AttributeUsage(AttributeTargets.Property)] public sealed class RangeAttribute : ValidationAttribute { public double Min { get; } public double Max { get; } public RangeAttribute(double min, double max) { Min min; Max max; } public override bool IsValid(object value) { if (value is null) return true; double num Convert.ToDouble(value); return num Min num Max; } }然后写一个校验器遍历任意对象的属性读取特性并执行校验public static class Validator { public static Liststring ValidateT(T obj) { var errors new Liststring(); foreach (var prop in typeof(T).GetProperties()) { foreach (ValidationAttribute attr in prop.GetCustomAttributes(typeof(ValidationAttribute), true)) { if (!attr.IsValid(prop.GetValue(obj))) { errors.Add(attr.ErrorMessage); } } } return errors; } }这已经是一个非常朴素的“迷你数据注解框架”了思路和ASP.NET Core的DataAnnotations同源。如果项目不想引入重量级校验库这种轻量方案完全够用。4.4 缓存别让特性读取拖垮性能反射读取特性是有代价的。每次调用GetCustomAttributeNET运行时都要找到对应元数据、构造特性对象、填充参数。在高频场景下比如每毫秒处理几万条消息的采集程序里每次都反射读特性会产生可见的性能开销。我的做法是在静态构造或者首次使用的时候把特性读取结果缓存起来用ConcurrentDictionary按类型或成员缓存后续读取直接走内存public static class ColumnCache { private static readonly ConcurrentDictionaryType, ColumnInfo[] Cache new(); public static ColumnInfo[] GetColumnsT() where T : class { return Cache.GetOrAdd(typeof(T), t { return t.GetProperties() .SelectMany(p p.GetCustomAttributesColumnAttribute() .Select(c new ColumnInfo { Property p, ColumnName c.ColumnName, Order c.Order, Description c.Description })) .OrderBy(c c.Order) .ToArray(); }); } }缓存之后后续所有读取都是毫秒级甚至微秒级。对于自己写框架或者类库的人来说缓存特性读取结果应当是默认动作而不是性能调优时才想起来的选项。5. 真实项目中的特性设计模式5.1 导出Excel的列映射有段时间我做的后台系统要支持大量数据导出Excel一开始每张表的列都用switch判断列名后来列多了就失控。改用特性方案后实体用它映射Excel列名导出器统一扫描特性生成表头和单元格。具体套路就是第一节那个ColumnAttribute挂在数据库实体的属性上导出组件从缓存拿列信息按顺序生成Excel。这样实体定义一变导出格式自动跟着变不需要额外维护一份配置表。5.2 上位机点位协议映射很多C#上位机项目要和PLC、OPC、串口设备打交道每种设备几十上百个点位协议看起来复杂映射规则却高度重复。我在一个工控项目中把点位地址定义成特性参数的定位值[DevicePoint(40001, PointType.HoldingRegister, Description 设备温度)] public ushort Temperature { get; set; }只要在实体属性上标注寄存器地址、点位类型和描述采集引擎就能靠反射自动生成一张点位映射表。新增设备时改实体定义即可业务逻辑完全不用动。实际测试下来这个方案比原来手工维护点位字典省心得多也降低了抄写地址时出错的概率。同样的思路也常见于依赖DllImport的场合把非托管函数签名、字符集、调用约定都通过特性声明出来相关说明和签名放一起代码的可读性和可维护性都上升一个档次。5.3 Unity里的特性Unity开发者接触特性的机会非常多最典型的[SerializeField]可以把私有字段显示在Inspector面板上。网格布局组件、菜单项声明、回调方法注册都会用到特性。Unity引擎自己定义了很多特性比如[RequireComponent]会自动挂载依赖组件[AddComponentMenu]能调整添加组件的菜单目录。这些都是C#特性的实际应用理解了语言的机制就能理解Unity很多“魔法”其实一点都不神秘。5.4 声明式权限控制用特性做权限控制也很常见本质上是拦截器模式的变形。给方法标注权限要求[Permission(order:export)] public async Task ExportOrders(DateTime start, DateTime end) { }权限检查组件在调用方法前通过反射读取方法的权限属性判断当前用户是否有对应权限没有就抛出异常或返回错误。这种方式把授权规则和业务代码彻底分离权限变更不需要改方法体只需要调整标注。这也是AOP“横切关注点”思想在语言层面的朴素实现理解特性反射机制后你能看懂很多框架的底层原理比如日志拦截、缓存处理、事务控制之类。6. 常见问题与避坑实录6.1 贴上特性却没有生效这是遇到最多的一个问题。原因多半是没有写读取逻辑。特性只是元数据标记系统不会主动为你做什么。如果问题出在框架内置特性上比如[Serializable]挂了但序列化仍失败那就要检查类里的字段是否有不可序列化类型特性只是一张许可证能不能顺利执行还要看代码本身。遇到“特性不生效”时先问自己谁负责读这个特性如果没有代码去读那就是预期行为不是Bug。6.2 Inherited与AllowMultiple的组合很绕Inheritedtrue的情况下子类会自动继承父类的特性但是和AllowMultiple组合后行为会让人意外。我实测过的结论是如果AllowMultiplefalse子类上出现同样的特性时子类会覆盖父类版本读取结果只看到子类的这一份如果AllowMultipletrue子类和父类的特性会同时返回。如果你设计的框架里读出来的特性数量不对先检查这两个参数。还有一个常见误区Inherited只对类继承生效接口的方法继承特性是要靠接口实现关系去查的GetCustomAttributes的继承参数不会自动帮你处理接口链。业务代码碰到这类需求时建议手动写遍历接口的逻辑。6.3 特性参数为什么必须是常量写特性构造函数时传变量编译器会直接报错“特性参数必须是常量、typeof表达式或数组初始值”。这是元数据表的结构决定的特性声明都要固化在程序集里运行时不可能把变量序列化进去。如果确实需要“动态配置”方案是让特性存一个Type或者枚举、字符串配置键运行时再通过IoC容器或配置中心解析出真实值。不要把特性本身设计成动态配置仓库。6.4 性能与泛型特性的一点拓展在极高频的调用路径上反射读取特性的性能不容低估。项目里能缓存就缓存能静态初始化就静态初始化。实测下来缓存和不缓存的差距在一个数量级以上对采集、网关这类程序意义很大。顺带提一句C# 11开始支持泛型特性了但限制比较严格比如只能用完全构造的类型不能用开放泛型。实际项目里大部分场景传统特性写法就足够泛型特性我目前用的不多也不建议新手一上来就尝试。结尾我个人在实际项目里的体会是特性的价值不在于“贴标签”这件事本身而在于它提供了一套声明式的元数据机制让代码的意图和实现分开。做上位机、写Unity、搞Web后端只要你能设计出合理的“读取器”特性就能帮你把横切逻辑收拢到统一的地方。平时写代码时如果觉得某个地方要写大量重复的判断可以停下来想一想这里是不是可以用特性声明化再加上反射统一处理。很多看着复杂的框架功能其实核心就是“一个特性类加一个反射读取器加一层缓存”。把这个模式想透了C#的很多库源码你都能看懂一大半。