写C#写了这么多年,说到"特性"这个词,很多刚入门的朋友第一反应是"不就是代码注释吗",第二反应是"好像哪里见过,但一直没搞明白"。其实特性(Attribute)是.NET体系里最被低估、最能提升开发效率的机制之一,它用一种声明式的思路,让代码自己"说出"自己的行为规则,省掉大量重复的样板逻辑。这篇东西,我想把C#特性的核心机制、内置特性的实战用法、自定义特性的完整落地步骤,以及我在实际项目中踩过的坑,一次性讲透。
1. 特性(Attribute)到底是什么,它解决了什么问题
1.1 特性不是注释,它是程序里的可执行元数据
很多人第一次接触[Obsolete]或者[Serializable]时,会觉得"这不就是给代码加了个标记吗"。这种理解对了一半。特性和注释的本质区别在于:注释是给人类看的,编译器直接无视;特性是给编译器、运行时和框架代码看的,它是一段结构化的元数据,最终会被编译进程序集(Assembly)的元数据表里。
我打个比方。注释就像你在快递盒上用马克笔写的"易碎品",但快递员不会因为这三个字就轻拿轻放。特性却是你贴的一张带二维码的电子标签,任何具备扫码能力的设备(也就是框架代码或你自己的代码)都能扫这张标签,读到里面的结构数据,然后决定要不要换个搬运方式。
这个区别直接决定了特性的价值:它让"代码的意图"可以被程序自动识别和响应。你可以在编译时让编译器根据特性改变编译结果,也可以在运行时用反射读取特性,动态调整类、方法、属性的行为,而不需要侵入业务代码本身。
1.2 特性与反射:一对天生的搭档
要理解特性,就必须理解反射(Reflection),因为99%的自定义特性只有在配合反射时才真正发挥价值。
反射的本质是"程序在运行时查看自己"的能力。C#编译出来的程序集包含丰富的元数据:类型名、方法名、参数列表、继承关系,当然也包括特性。通过System.Reflection命名空间下的API,你可以在运行时遍历一个类有哪些方法、哪些属性、哪些字段,也能读取它们身上挂了哪些特性。
特性负责"声明",反射负责"读取",两者合在一起,就形成了一套插件式的扩展机制。框架作者定义好特性作为约定,使用者只需在业务代码上打标记,框架代码在运行时扫描这些标记,执行对应的逻辑。整个过程,业务代码只关心"是什么",不关心"怎么做"。
这种设计思想在你第一次自己实现时会特别有感触。我建议你动手写一个只有几十行的小Demo:定义几个特性,定义一个类,用反射遍历这个类的方法和属性,把所有特性都打印出来。跑完这个Demo,你对"元数据"这个概念就再也不会觉得抽象了。
2. 内置特性实战:从常用到进阶
C#类库本身提供了大量内置特性,覆盖了编译提示、序列化控制、调试辅助、调用者信息等场景。我挑几个高频的、值得你注意的讲讲。
2.1 编译校验和提示类:Obsolete、Conditional
[Obsolete]是入门必学的一个特性,它的作用是标记一段代码已经过时,建议调用方使用替代方案。它可以带一个布尔参数,表示"是否把警告升级为编译错误"。比如:
[Obsolete("请使用 ComputeV2 方法,旧方法即将移除", true)] public void ComputeV1() { }如果我传了true,任何调用ComputeV1的代码都会直接编译失败。这个能力很实用,在团队协作里能强制推动接口升级,而不是让旧接口一直静默存在,最后变成技术债。
[Conditional]是个有意思的特性,它控制方法在什么编译符号下才生效。最常见的用法是调试输出:
[Conditional("DEBUG")] public void Log(string message) { ... }标记了这个特性的方法,在非DEBUG配置下编译时,所有调用点都会被编译器"抠掉",方法体本身也基本不会带进程序集。这比在方法内部用#if DEBUG包裹要优雅得多,因为调用代码不用跟着写条件编译指令,保持业务代码清爽。
2.2 序列化与调试辅助类:JsonIgnore、CallerMemberName、DebuggerStepThrough
序列化是特性的重灾区,也是大家最好理解的场景。不管是System.Text.Json还是Newtonsoft.Json,都依赖特性来控制序列化行为,比如忽略属性[JsonIgnore]、指定字段名[JsonPropertyName("user_name")]。这里我就不展开了,重点说一个反直觉的实战经验:特性会改变框架的默认逻辑,而这种改变不总是你想要的。
比如你用[JsonIgnore]忽略了一个属性,后来第三方库依赖这个属性做序列化,两边约定不一致,就会产生隐蔽的Bug。所以给公共模型加特性前,最好想一想:这个模型会在多少个地方被序列化?每个序列化场景都希望忽略它吗?
[CallerMemberName]是值得所有写类库的人学习的特性。它用在一个可选字符串参数上,编译器会自动把调用方的成员名填进去。经典场景是INotifyPropertyChanged:
protected void SetProperty<T>(ref T field, T value, [CallerMemberName] string propertyName = null) { if (!Equals(field, value)) { field = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }调用的时候你完全不用传属性名,编译器替我生成。这消除了一个很大的痛点:以前手动写propertyName字符串,一改属性名就容易漏,现在重构起来毫无负担。
[DebuggerStepThrough]是我在排查性能问题时常用的一个特性。它告诉调试器:单步执行时,不要进入标记了这个特性的方法内部。这个特性主要用于基础设施代码,比如日志封装、属性访问器的样板逻辑。加了它以后,调试体验会好很多,不用一遍遍"踩进"无关紧要的赋值代码里。
2.3 调用方信息三件套:CallerFilePath、CallerLineNumber、CallerMemberName
这三个特性配合起来可以做一个非常好用的诊断日志辅助类。比如写一个日志方法:
public void Info(string message, [CallerFilePath] string file = "", [CallerLineNumber] int line = 0, [CallerMemberName] string member = "") { Console.WriteLine($"{Path.GetFileName(file)}:{line} {member}: {message}"); }所有调用点自动带上文件名、行号、方法名,排查线上问题时能直接定位到具体代码位置。这套方案我现在还在很多项目里用,成本极低,回报极高。
2.4 探索场景:Flags、DEBUGAble、ThreadStatic
[Flags]是用来修饰枚举的,它告诉编译器和阅读者,这个枚举是位标志集合,可以组合:
[Flags] public enum Permissions { None = 0, Read = 1, Write = 2, Execute = 4 }配合Enum.HasFlag方法使用,可以做权限自由组合。这里有个细节:定义位标志枚举时,值必须是2的幂,否则不同的组合会重叠,导致无法正确判断包含哪个标志。关于Flags枚举,我是踩过坑的,因为越用越觉得它实用,属于内置特性里被低估的一类。
3. 手写一个自定义特性的完整流程
内置特性解决通用问题,真正体现一个团队工程能力的,是自定义特性。我拿一个真实场景来演示:给Model属性打标记,自动生成对应数据库字段名。这个方案在不少ORM框架里都能看到,但自己实现一遍,你对整个机制的理解会完全不同。
3.1 定义Attribute类:继承、命名与构造参数
创建自定义特性的第一步,是写一个继承自System.Attribute的类。注意类名要加Attribute后缀,而且C#编译器支持省略后缀,比如你写[ColumnName("user_name")]时,编译器会自动去找ColumnNameAttribute类。
定义特性时可以设计构造函数参数和属性,这些参数在挂特性时相当于调用构造方法:
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] public sealed class ColumnNameAttribute : Attribute { public string Name { get; } public bool IsPrimaryKey { get; set; } public ColumnNameAttribute(string name) { Name = name; } }这段代码里有几个关键点。AttributeUsage是给特性用的特性,相当于"特性的特性",它限定这个特性可以用在哪些代码元素上。我把目标限定为属性和字段,这样它就不会误挂到方法上。AllowMultiple = false表示一个属性能不能挂多个同名特性。sealed修饰符我是建议加上的,一个不准备被继承的特性类,封死它是稳妥的选择。
3.2 AttributeUsage三要素:AttributeTargets、AllowMultiple、Inherited
AttributeTargets是个枚举,支持位组合,可以指定类、方法、属性、参数、返回值、程序集等几十种目标。Inherited这个参数我单独拿出来说,因为它控制的是特性会不会被子类继承,默认值是true。
这个默认值有好有坏。好处是你在基类上放一个特性,子类不用重复标记也能被识别;坏处是,如果你希望特性只作用于当前类,就必须显式设Inherited = false,否则容易在框架扫描时出现"父类有、子类没有预期中的行为"这种尴尬情况。后面我会专门讲一个我踩过的继承坑。
3.3 用反射读取自定义特性,实现一个简易ORM映射器
特性定义好之后,关键在于怎么读取。我用一个完整的迷你案例演示:一个类通过特性映射到数据库表,我写一段代码在运行时动态生成INSERT语句。
先定义一个表映射特性:
[AttributeUsage(AttributeTargets.Class)] public sealed class TableAttribute : Attribute { public string TableName { get; } public TableAttribute(string tableName) => TableName = tableName; }再定义一个字段映射特性:
[AttributeUsage(AttributeTargets.Property)] public sealed class ColumnAttribute : Attribute { public string ColumnName { get; } public ColumnAttribute(string columnName) => ColumnName = columnName; }然后写一个简单的实体类:
[Table("users")] public class User { [Column("id")] public int Id { get; set; } [Column("user_name")] public string UserName { get; set; } public string Remark { get; set; } // 没加特性,映射时忽略 }最后是反射读取逻辑,动态生成SQL:
public static string GenerateInsertSql<T>(T entity) { var type = typeof(T); var tableAttr = type.GetCustomAttribute<TableAttribute>(); if (tableAttr == null) throw new InvalidOperationException($"类型 {type.Name} 缺少 Table 特性"); var propertyInfos = type.GetProperties() .Where(p => p.GetCustomAttribute<ColumnAttribute>() != null) .ToList(); var columnNames = propertyInfos .Select(p => p.GetCustomAttribute<ColumnAttribute>().ColumnName); var columnValues = propertyInfos .Select(p => $"'{p.GetValue(entity)}'"); return $"INSERT INTO {tableAttr.TableName} ({string.Join(", ", columnNames)}) " + $"VALUES ({string.Join(", ", columnValues)})"; }这段代码的关键逻辑很简单:通过GetCustomAttribute<T>()获取特性实例,用LINQ过滤出有[Column]的属性,再拼接SQL。跑一遍这个Demo你就能看到,User类里的Remark属性因为没加特性,被自动忽略了。这其实就是很多ORM框架生成SQL的核心雏形。
注意:真实项目中生成SQL不能用字符串拼接,有SQL注入风险,这里仅仅为了演示反射和特性的配合逻辑。
做完这个Demo,你会对"声明式编程"有非常直观的体验。业务侧只需要在属性上打一个标记,框架侧就能自动执行一套逻辑,业务代码里没有一行SQL、没有一串分支判断。这种模式一旦用上,你就再也回不去"写一堆if else凑参数"的旧方式了。
3.4 运行时读取与设计期读取
反射是运行时读取特性的唯一途径,但特性本身还有一个应用方向是"编译期读取",典型代表是Source Generator(源生成器)。源生成器在编译阶段扫描代码里的特性,生成额外代码,这个过程不会带来反射的性能开销。
如果你对性能敏感,比如在游戏热更新框架、高频调用路径上频繁读取特性,建议研究一下源生成器方案。它的思路是:编译时读特性、编译时生成代码,运行时代码已经是生成好的成品。这也解释了为什么很多现代框架越来越倾向于"特性 + 源生成器"的组合,而不是"特性 + 运行时反射"。
4. 实操中的坑与排查实录
写特性的代码不难,难的是写完之后在真实项目里不出幺蛾子。我把这几年踩过的坑集中整理一下,基本覆盖了高频问题。
4.1 特性继承的暗坑
AttributeUsage里的Inherited默认是true,意味着基类上的特性,在子类上通过反射同样能被读到。这个问题在实现基类控制器、基类实体时尤其明显。
我遇到过一个具体案例。某框架用自定义特性控制接口权限,我在抽象基控制器上加了权限特性,子类控制器继承了权限,但其中有个子类需要放宽权限。我在子类上重新标了更宽松的特性,结果权限判断还是走基类的逻辑。排查半天,发现是框架读取时用了GetCustomAttributes(false)还是true参数的问题,以及特性合并策略没有处理"子类覆盖父类"的情况。
排查方法建议写成这样:读取特性时,明确指定继承行为,不要依赖模糊的默认值:
var attrs = type.GetCustomAttributes(typeof(PermissionAttribute), true);然后通过判断特性来源(DeclaringType)决定是基类还是子类。如果框架逻辑比较复杂,建议在AttributeUsage里直接显式指定Inherited = false,然后自己实现链式查找逻辑,反而更可控。
4.2 反射性能:特性读取要加缓存
反射的开销比直接调用高出几十倍甚至上百倍。如果在请求处理的热路径上写GetCustomAttribute,高并发下性能会肉眼可见地变差。最直接的优化是缓存。
我的做法是:把类型上的特性读取结果缓存到ConcurrentDictionary里,键是Type,值是读取好的特性实例,或者干脆缓存解析后的配置对象:
private static readonly ConcurrentDictionary<Type, Dictionary<string, string>> Cache = new(); public static Dictionary<string, string> GetColumnMapping(Type type) { return Cache.GetOrAdd(type, t => { return t.GetProperties() .Where(p => p.GetCustomAttribute<ColumnAttribute>() != null) .ToDictionary( p => p.Name, p => p.GetCustomAttribute<ColumnAttribute>().ColumnName); }); }这种"首次反射,后续走缓存"的模式,基本可以消除反射带来的性能损耗。如果你用的是.NET 6以上的版本,也可以考虑Type的缓存API或者源生成器方案,能把性能压到极致。
4.3 特性参数的编译期约束
你只能在特性构造函数里传"编译期常量":字符串、数值、布尔值、Type、枚举,或者这些类型的一维数组。想传new List<string>或者动态计算的值,编译器会直接报错。
这个限制的根源是:特性数据要直接写入程序集元数据表,必须是可静态表示的恒定值。理解了这个限制,你在设计特性的时候就会提前想清楚:不要在构造参数里塞运行时才能确定的对象,而是塞一个键、一个类型、一个枚举,让读取方去解释。
我见过不少人试图在特性里放一个Func或者委托,都是行不通的。更合理的方案是:特性里放一个Action枚举或策略类型,运行时根据枚举值选择对应的处理器。
4.4 命名的坑:Attribute后缀与编译器查找规则
特性类的命名约定是以Attribute结尾。C#编译器允许你在使用[Obsolete]时省略Attribute后缀,它会在背后尝试ObsoleteAttribute。
这里有个实际的坑:如果你定义了一个类叫ColumnName(不带Attribute后缀),同时定义了ColumnNameAttribute,那编译器在解析[ColumnName]时就可能产生歧义。行业规则是:特性类一定要以Attribute结尾,且不要定义一个去掉后缀后重名的类。
另外,Attribute本身不是sealed,普通类也可以继承它。但不要轻易做多层特性继承,特性继承链越深,运行时读取的判断逻辑就越复杂,排查越困难。能用组合解决就不要硬上继承。
4.5 排查实战:特性怎么不见了
我在排查线上问题时遇到过这样一个场景:接口文档生成系统突然不输出某个API的说明信息,原因就是一个方法上的特性在继承接口实现时丢失了。
C#的方法特性在接口和实现类之间不是自动传递的。接口方法上有特性,实现类方法上是不会自动带上的。如果框架扫描的是实现类,那接口上的特性就全部失效。解决思路有两个:一是在实现方法上也标记特性,二是在框架读取时先判断类型是否实现了接口,然后去接口链路上读取。
这个坑很有代表性,它说明了一个道理:特性的作用范围取决于"谁去读它",而不是"你写了它"。写特性之前,必须先搞清楚目标框架的扫描规则,否则特性就是个摆设。
5. 特性的进阶应用:让代码更优雅
当你掌握了自定义特性的基础,就可以往更复杂的场景扩展了。特性不只能做标记,配合一些设计模式,它能大幅提升代码的可读性和扩展性。
5.1 数据验证框架:声明式校验
假设你要写一个参数验证模块。常见做法是:
if (string.IsNullOrEmpty(user.Name)) throw ... if (user.Age < 18) throw ...这种写法的问题在于校验规则散落在业务逻辑里,重复且难维护。用特性重构一下,规则就能跟着属性走:
public class RegisterRequest { [Required] [StringLength(20)] public string UserName { get; set; } [Range(18, 60)] public int Age { get; set; } }配合一个通用的验证器类,用反射读取所有"校验特性",依次执行校验,不合法就返回错误信息。这样一来,每个DTO的属性自带规则,新增一种校验只要新写一个特性类,不需要动已有的业务代码。
这个模式在ASP.NET Core里其实被内置了,就是DataAnnotations那一套,[Required]、[Range]、[RegularExpression]。自己实现一遍,你就知道框架默认帮你做了多少事情,同时也会知道它在复杂规则面前的局限,比如跨属性依赖的校验就很难用内置特性优雅表达。
5.2 AOP思维:用特性实现拦截器
AOP(面向切面编程)的核心思想是"把横切逻辑从业务代码中抽离"。特性天然适合做这件事的载体。
比如日志记录、权限校验、缓存控制、性能计时,这些都是典型的横切逻辑。你可以定义像[Log]、[Cached]这样的特性,在框架层面统一拦截:执行方法前检查特性,决定要不要记录日志、要不要走缓存、权限够不够。
但这里要提醒一个关键点:普通的反射读取特性,只能做"静态检查",没法真正"拦截"方法调用,除非配合动态代理、DispatchProxy(.NET内置了一个在运行时生成代理类的类)或者AOP框架。如果是静态检查,你可以实现"在方法执行前先校验特性";真要拦截,就需要在方法外包裹一层代理,让调用方走代理而不是直接调用目标方法。
我自己的经验是:拦截器方案要谨慎引入。它确实能消掉大量样板代码,但也会让调试变难,因为你visual studio单步跟踪时,执行流会跳进代理类里,异常堆栈里会多出好几层调用。团队里如果都是新手,这种"魔法"会让人崩溃。所以,AOP特性用在小范围、低风险、横切逻辑成熟的场景最稳妥。
5.3 特性与现代C#语言特性结合:record、模式匹配
C# 9之后引入了record类型,配合init访问器,让不可变对象建模变得很自然。特性与record能很好地协作。record的参数列表属性同样可以挂特性:
public record CreateProductRequest( [property: Required] string Name, [property: Range(0.01, 999999)] decimal Price);这里有一个重要细节:[property: Required]这种写法,明确指定特性的目标是属性而不是构造函数参数。这在小细节上很关键,因为同一个构造参数,特性的目标不同(参数还是属性),读取路径完全不同。
C# 8以上还有模式匹配语法,配合特性选择的场景,在一些框架里能看到"按特性做策略分发"的组合:根据元素上的特性标签,用switch表达式选择对应做法的逻辑。这种写法干净利落,但不要堆得太猛,否则会变成"特性驱动的一行流",失去可读性。
5.4 依赖注入与特性标记
在依赖注入场景,特性也大有用武之地。比如在模块化项目里,你可以用特性标记服务实现类:
[Service(ServiceLifetime.Scoped)] public class OrderService : IOrderService { }然后在程序启动时,通过反射扫描程序集里所有带[Service]特性的类型,自动注册到依赖注入容器。这样新加一个服务类,只要加上特性,不用频繁去修改注册代码。
我做过一个中等规模的模块化项目,这个方案确实省了很多事。选型时要处理两个问题:一是程序集扫描的性能,一次性启动阶段扫描,可以用缓存解决;二是生命周期枚举如何传递,直接在特性构造函数里传参数最直观,我就在构造参数里用枚举区分Singleton、Scoped、Transient。
6. 特性选型与设计规范:写给团队的建议
6.1 特性设计检查清单
结合几年下来的经验,我总结了一份团队使用特性的检查清单:
- 特性只放编译期常量,绝不放运行时对象。
- 明确
AllowMultiple、Inherited,不依赖默认值。 - 特性类命名一定带
Attribute后缀。 - 特性目标用
AttributeTargets精确限定,不要图省事用All。 - 读取特性的逻辑集中到一个工具类,方便后续统一加缓存。
- 特性影响面要写注释,说明"谁会读它、读到后做什么"。
- 如果一个特性读取逻辑在多个框架层出现,考虑源生成器。
这个清单不是我凭空想的,每一条背后都有真实的翻车记录。尤其是最后两条,很多项目里特性被读的地方不明确,结果改了一个特性的语义,牵连出好几个模块的Bug,排查起来特别痛苦。
6.2 框架设计与团队协作:什么时候用特性,什么时候别用
特性不是银弹。过度使用会让代码变得"隐晦",新人进项目看到一堆自定义特性,完全不知道这些标记背后发生了什么,调试的时候就像在看魔法。
我的判断标准是:如果横切逻辑是明确、稳定、通用的,特性很合适;如果某个特性的行为随着业务变化频繁调整,或者只有一两个业务类会用到,那不如直接写普通方法,别为了"炫技"强行抽象。
另外,特性的读取端最好做成"可配置"的,比如利用开关控制是否启用某个特性处理器。这样在线上出问题时,你还有一条退路,不用发布新版本才能关闭特性逻辑。
6.3 从特性到源生成器:下一站
如果你觉得特性配反射的方案已经玩得很溜,下一步可以研究源生成器。它能解决反射的性能问题,也能在编译期生成强类型的代码。本质上,源生成器也是一个"特性的应用端",但它运行在编译期,不是在运行时。
选哪个方案有个简单的判断原则:运行期读特性周期短、频率高,优先源生成器;如果只是启动时扫描一次,缓存后的反射完全够用。
C#的特性机制,从入门到熟练,其实是一条挺清晰的学习路径:先用好内置特性,再会写自定义特性,再到配合反射做框架级功能,最后进阶到源生成器。这套能力掌握了以后,你写代码的眼界会完全不一样——你不再只是在"写具体逻辑",而是在设计"让别人写逻辑时更舒服的规则"。
7. 常见问题速查表
整理一份我在工作和学员交流中最常见的问题,方便你排查时直接对号入座。
| 问题 | 原因 | 排查方向 |
|---|---|---|
| 特性在子类上读不到 | Inherited=false,或者读取时传的继承参数不符合预期 | 检查AttributeUsage定义,检查GetCustomAttributes第二参数 |
| 方法上的特性在接口实现时丢失 | 接口与实现的特性不自动传递 | 在实现类上也标记,或读取时遍历接口链 |
GetCustomAttribute<T>()返回null | 特性没挂上,或目标类型不匹配 | 确认挂载目标(类/方法/属性)与读取API目标一致 |
| 构造函数参数报"不是常量" | 特性参数必须是编译期常量 | 换成字符串、数值、布尔、Type、枚举或其一维数组 |
| 同一个属性挂两个同名特性不生效 | AllowMultiple=false | 改成AllowMultiple=true,再读取所有实例 |
| 反射读取特性性能差 | 每次调用都触发反射 | 加缓存、用GetOrAdd缓存到字典 |
[property: X]和[X]行为不同 | 特性目标不同,一个在属性上、一个在参数上 | 明确特性目标,按需加property:等前缀 |
| 序列化字段名被框架改掉了 | 加上了[JsonPropertyName]类特性,有框架约定冲突 | 检查所有序列化消费端,确认改名影响 |
| 特性的读取逻辑找不到 | 特性被读的位置分散,缺少统一封装 | 把所有读取逻辑收敛到一个工具类 |
| 枚举值组合判断错误 | 忘了加[Flags]或者值不是2的幂 | 加[Flags],枚举值改为1、2、4、8 |
这张表可以打印出来放在工位上。我不少次排查问题时就是靠这张表快速定位,省了很多翻文档的时间。
8. 我的几点实操体会
最后说点掏心窝的话。特性这个东西,学起来不复杂,但要真的用好,需要一种"框架思维"——总是想着怎么把规则沉淀下来,让代码自己说话。我见过很多高级开发,写业务代码一流,但一到抽象通用能力就习惯性地用一堆接口和工厂模式硬怼,反而把简单的事搞复杂。特性提供了一种更轻量、更直观的抽象路径,值得你认真掌握。
关于学习路径,我建议你不要一上来就研究那些复杂框架的源码,而是先写几个小Demo:自己定义一个特性并用反射读出来、实现一个自动生成SQL的小工具、给DTO加一套校验规则。这三个小目标练完,特性的核心能力你就拿捏得差不多了。
另外,在团队项目里推广特性时,记得搭配一份简短的规范文档,说明"哪些地方可以用特性、特性由谁来读取、如何排查特性相关的问题"。规范不用长,两三页纸足够,但它能避免别人把你的代码当成魔法,也避免后来者乱加特性把系统搞乱。毕竟,代码最终是给人看的,特性只是让程序更能理解意图的一种语言而已。