第5章 成员设计
1 成员设计的通用规范
1 成员重载
形参要求:
-
DO
:在较长的重载中,应使用 描述 性的参数名来说明较短的重载使用的默认值。以如下代码举例,ignoreCase 暗示“短重载大小写敏感”,如果参数名改为 caseSensitive,将不会隐含该含义。
public class Type { public MethodInfo GetMethod(string name); // ignoreCase = false public MethodInfo GetMethod(string name, Boolean ignoreCase); }
-
DO
:重载成员的参数名要保持一致,且参数顺序一致。// 正确做法 public int IndexOf(string value) { ... } public int IndexOf(string value, int startIndex) { ... } // 错误做法 public int IndexOf(string value) { ... } public int IndexOf(string str, int startIndex) { ... }
同名参数应该出现在相同的位置。极个别情况可以违反该规则,如 params 修饰的参数数组、out 参数
public EventLog(); public EventLog(string logName); public EventLog(string logName, string machineName); public EventLog(string logName, string machineName, string source);
实现要求:
-
DO
:如果需要可扩展,则要将最 长 的重载设置成虚成员。public class String { public int IndexOf(string s){ return IndexOf(s, 0); } public int IndexOf(string s, int startIndex){ return IndexOf(s, startIndex, s.Length); } public virtual int IndexOf(string s, int startIndex, int count){ // 干活 } }
-
DON'T
:禁止 通过 ref、out 和 in 修饰符对成员进行重载。public class SomeType { public void SomeMethod(string name) { ... } // 编译不会报错 public void SomeMethod(out string name) { ... } }
-
DON'T
:同一位置的参数不应类型相似,语义不同。public void Print(long value, string terminator){ Console.Write("{0}{1}", value, terminator); } public void Print(int repetitions, string str){ for(int i = 0; i < repetitions; i++) Console.Write(str); }
对于一些语言(特别是动态类型语言)无法解析这样的重载。
不过,如果重载的方法做的事情完全一样,调用哪个都无关紧要,则可以重载:
public static class Console { public void WriteLine(long value) { ... } public void WriteLine(int value) { ... } }
-
DO
:允许 在传递参数时将可选参数设为 null。这样的目的是避免使用者进行如下的显式检查:
if (geometry == null) DrawGeometry(brush, pen); else DrawGeometry(brush, pen, geometry);
-
DON'T
:如果一个非泛型方法的重载使用了泛型参数,该方法的其他重载不要 使用特定的类型 。public class PrettyPrinter<T> { public void PrettyPrint(string format) { ... } public void PrettyPrint(T otherPrinter) { ... } public void PrettyPrint(T otherPrinter, string format) { ... } } // 此时,如果用T是string类型,泛型方法PrettyPrint(T)方法将无法被调用 var printer = new PrettyPrinter<string>(); ... // C#重载认为这是在调用PrettyPrint(string),而非PrettyPrinter<T> printer.PrettyPrint("hello world");
此条规则不适用于泛型方法。
默认参数
-
CONSIDER
:建议在一个方法的最长重载中使用默认参数。如果该方法有两个及以上的默认参数,提供一个没有默认参数的简版重载。默认参数是较高级的特性,有些开发者不理解默认参数的意义。提供简版重载便于开发者调用。
-
DON'T
:接口方法和类的虚方法 不要 提供任何默认参数, CancellationToken
参数除外。如下代码将输出: 20 、 10 、 5 ,符合预期吗?
public interface IExample { void PrintValue(int value = 5); } public class Example : IExample { public virtual void PrintValue(int value = 10) { Console.WriteLine(value); } } public class DerivedExample : Example { public override void PrintValue(int value = 20){ base.PrintValue(value); } } void Main() { var derived = new DerivedExample(); Example e = derived; IExample ie = derived; derived.PrintValue(); e.PrintValue(); ie.PrintValue(); }
-
DON'T
:版本发布后,默认参数的默认值 不可 更改。除非发布时该默认值是一个非法的哨兵值,以表明应该使用运行时的默认值。
// 此处provider是一个非法的哨兵值,后续版本可对它进行更改。 public static BigInteger Parse(ReadOnlySpan<char> value, IFormatProvider provider = null) { ... }
-
AVOID
:同一方法的两个重载,避免同时使用默认参数;否则会导致调用时出现争议。
-
DON'T
:默认参数相同时,必选参数必须不同;如果必须参数相同,即方法签名相同,无法通过编译。
-
DO
:相同方法的不同重载,默认参数的默认值必须相同。既然是不同的重载,方法签名必然不同,若使用同一默认参数,则默认值要相同。
-
DO
:在为现有方法添加新的可选参数时,要将所有默认参数都转移至整个新的、更长的重载中。同时将旧方法标记为
[EditorBrowsable(EditorBrowsableState.Never)]
。// 原方法的签名: // public static int TestMethod(string filename, bool ignoreCase = false) // 将原方法的ignoreCase的默认值去掉 [EditorBrowsable(EditorBrowsableState.Never)] public static int TestMethod(string filename, bool ignoreCase) { ... } // 添加新方法,为ignoreCase添加默认值 public static int TestMethod(string filename, bool ignoreCase = false, bool size = 512) { ... }
2 显式地实现接口成员
显式地实现接口方法如下:
public struct Int32 : IConvertible {
// 显式实现
int IConvertible.ToInt32() { ... }
// 隐式实现
public long ToInt64() { ... }
}
// 调用
int i = 0;
i.ToInt32(); // 无法调用
((IConvertible)i).ToInt32(); // 可以调用
-
AVOID
:避免显式实现接口成员。对调用者不友好,它不会出现在公有成员列表中,且在值类型上会导致不必要的 装箱 。
一般来说,仅当 接口名和方法名出现了冲突 才会这么做:
class Collection : IEnumerable { IEnumerator IEnumerable.GetEnumerator() { ... } public MyEnumerator GetEnumerator() { ... } }
-
CONSIDER
:如果希望接口成员只能通过 接口 调用,应显式实现接口成员。主要用于
.Design
中的类。例如:ICollection<T>.IsReadOnly
主要用于数据绑定基础类通过ICollection<T>
接口访问,该接口的实现类几乎不会访问此方法。因此List<T>
显式地实现了该接口成员。
-
CONSIDER
:通过显式地实现接口成员模拟 变体 。例如,
IList
的实现通过显式实现隐藏了弱类型成员,并增加强类型公有成员,以改变参数和返回值类型:public class StringCollection : IList { public string this[int index] { ... } // 显式实现以隐藏该接口 object IList.this[int index] { ... } }
-
CONSIDER
:如果想用名字更合适的成员隐藏接口成员,可以显式地实现接口成员。这相当于对成员进行重命名。如
System.IO.FileStream
显式地实现了IDisposable.Dispose
,并将它重命名为FileStream.Close
。// 以下并非 FileStream 的真正实现,仅用于演示 public class FileStream : IDisposable { void IDisposable.Dispose() { Close(); } public void Close() { ... } }
此方法尽量少用,相比名字不理想,重命名带来的混乱是更大的问题。
-
DON'T
:不要把接口成员的显式实现当作安全壁垒。只要把实例强制转换为 接口 ,任何代码都可以调用该成员。
-
DO
:如果希望子类能覆写父类的显式实现的接口成员,要为显式实现的接口成员提供具有 相同功能的受保护的虚 成员。否则子类无法通过
base
的方式调用父类实现的接口成员public class List<T> : ISerializable { ... void Iserializable.GetObjectData (SerializationInfo info, StreamingContext context) { GetObjectData(info, context); } protected virtual void GetObjectData (SerializationInfo info, StreamingContext context) { ... } }
接口重新实现的替代方法
即使是显式实现的成员,接口重新实现还是容易出问题,这是因为:
- 子类实例无法调用基类的方法
当然,如下两个解决方案也无法解决此问题
- 定义基类时不能预测方法是否会重新实现,或无法接受重新实现后的潜在问题
让子类通过
new
关键字隐藏基类方法是最不理想的方式。基类有两种方法可以避免:-
当隐式实现成员时, 将其标记为
virtual
-
当显式实现成员时,如果能够预测子类可能要重写某些逻辑,则使用下面的模式:
public class TextBox : IUndoable { void IUndoable.Undo() => Undo(); // Calls method below protected virtual void Undo() => throw new NotSupportedException(); } public class RichTextBox : TextBox { protected override void Undo() => Console.WriteLine ("RichTextBox.Undo"); }
如果你不希望添加任何的子类,则可以 把类标记为
sealed
以制止接口的重新实现。
3 属性和方法之间的选择
-
CONSIDER
:如果成员表示类型的一种逻辑属性,则应定义为属性成员。例如
Button.Color
是属性,因为颜色是按钮的一种属性。
-
DO
:如果属性值存储在进程内存中,且目的仅仅是为了访问该值,应使用属性而非方法。
-
DO
:以下情况应使用方法而非属性:以下可以分为几类: 耗时间 、 耗内存 、 有副作用 、 是一个动作 、 不可预测 。
-
该操作比字段访问慢几个数量级。特别是那些访问网络或文件系统的操作。
-
该操作是一个转换操作,比如
Object.ToString
方法。 -
该操作在每次调用时都返回不同的结果,即使传入参数不变。如
Gudi.NewGuid
方法。.NET 中的DateTime.Now
方法也应该设计成方法,而非属性。 -
该操作有严重的、显而易见的副作用。
如 WinForm 控件的 Handle,调试器会为它创建句柄。
-
该操作返回内部状态的一个副本(不包括返回的值类型副本)。
-
该操作返回一个数组。
通常我们会返回内部数组的副本,但这样会导致代码效率低下。如果开发者调用的是方法,他会清晰的意识到该方法会有副作用,反而会主动将方法的返回值保存起来,以便进行操作:
// 坏设计 Company data = GetCompanyData("MSFT"); for (int i = 0; i < data.Employees.Length; i++) { if (data.Employees[i].Alias == "kcwalina") { ... } } // 好设计 Company data = GetCompanyData("MSFT"); Employees[] employees = data.GetEmployees(); for (int i = 0; i < employees.Length; i++) { if (employees[i].Alias == "kcwalina") { ... } }
这种情况还可以改用只读的
ReadOnlyCollection<T>
或读写受控的Collection<T>
子类:public ReadOnlyCollection<Employee> Employees { get { return roEmployees;} } private Employee[] employees; private ReadOnlyCollection<Employee> roEmployees;
-
属性的使用有一条简单的原则:属性只用于简单的 计算 或对 数据 简单的访问。
2 属性的设计
-
DO
:如果调用方不应该改变属性的值,它应该是 只读 属性。注意:如果属性的类型是可修改的引用类型,即使属性只读,调用方也能改变属性的值。
-
DON'T
:不要有 只写 属性,或 setter 的可访问性比 getter 更广 。如果不能为属性提供 getter,则该成员应设计为 方法 。如
AppDomain
有一个名为SetCachePath
的方法,没有一个叫CachePath
的只写属性。
-
DO
:要为属性提供合理的默认值,并确保默认值不会导致安全漏洞或低效率代码。
-
DO
:允许用户以任何顺序设计属性值,即使会导致对象在短时间内处于无效状态。属性相互关联很正常,有些属性值相互组合是无效的。此时应推迟抛出异常,在对象真正用到这些关联属性时才应抛出。
-
DO
:若 setter 抛出异常,要保留 原值 。
-
AVOID
: 避免 在 getter 中抛出异常。如果一个 getter 会抛出异常,它应该被设计为 方法 。
注意:此条规则不适用于 索引器 。
1 索引属性的设计
索引属性是一种特殊的属性,它可以有参数,它的调用语法与数组索引相似。
public class String {
public char this[int index] {
get { ... }
}
}
string city = "Seattle";
Console.WriteLine(city[0]);
注意:索引器可能会在循环中使用,所以要格外小心,一定要非常简单。
-
CONSIDER
:通过索引器的方式让用户访问 存储在内部数组中的 数据。
如string
。
-
CONSIDER
:为代表元素集合的类型提供索引器。
如string
。
-
AVOID
:避免使用有 一 个以上参数的索引属性。如果索引器的参数超过 一 个,它应该被设计为方法。可以考虑以 Get 或 Set 作为方法名。
-
AVOID
:应使用如下类型作为索引器的参数:-
System.Int32
-
System.Int64
-
System.String
-
System.Object
-
System.Range
-
System.Index
- 枚举
如果考虑其他类型做索引器参数,它应该被设计为方法。可以考虑以 Get 或 Set 作为方法名。
-
-
DO
:要将 Item
名称用于索引属性。除非有明显更好的名字(例如
System.String
的Chars
属性)。在 C# 中,索引器默认的名字是
Item
。IndexerNameAttribute
可以用来对这个名字进行定制。
-
DON'T
:不要 同时提供语义上等价的索引器和方法。以下例子应该把索引器改成方法:
// 坏设计 public class Type{ [System.Runtime.CompilerServices.IndexerNameAttribute("Members")] public MemberInfo this[string memberName] { ... } public MemberInfo GetMember(string memberName, bool ignoreCase) { ... } }
-
DO
:要从接收System.Range
的索引器中返回与声明类型 相同 的类型。// 正确,从基于范围的索引器中返回声明的类型。 public partial class SomeCollection { public SomeCollection this[Range range] { get { ... } } } // 错误,从基于范围的索引器中返回错误的类型声明。 public partial class SomeCollection { public SomeValue[] this[Range range] { get { ... } } }
2 当属性值发生改变时的通知事件
-
CONSIDER
:在高层 API(通常是设计器组件)的属性值被修改时,触发通知事件。因通知事件有额外开销,且可能会频繁触发,低层 API 不值得触发此类通知事件。
-
CONSIDER
:在属性值 被外界修改 时触发通知事件。如
TextBox
控件的Text
属性,当用户键入文本时,属性值会自动改变,同时触发事件。
3 构造函数的设计
构造函数有两种:类型构造函数和实例构造函数:
public class Customer {
// 实例构造函数
public Customer() { ... }
// 类型构造函数
static Customer() { ... }
}
-
CONSIDER
:提供简单的构造函数,最好是默认构造函数。构造函数是创建类型实例的最自然的方式。大多数开发人员在开发时会优先尝试使用构造函数,其次才是工厂方法等方式。
-
CONSIDER
:如果构造函数无法和新实例的语义相对应,或遵循构造函数的设计让人感觉不合理,考虑使用 静态工厂方法 来替代。
-
DO
:应把构造函数的参数列表作为设置主要属性的快捷方式。以下三种方法是等价的:
// 1 var applicationLog = new EventLog(); applicationLog.MachineName = "BillingServer"; applicationLog.Log = "Application"; // 2 var applicationLog = new EventLog("Application"); applicationLog.MachineName = "BillingServer"; // 3 var applicationLog = new EventLog("Application", "BillingServer");
-
DO
:如果定义构造函数的目的就是为了设置相应的属性,则属性名和参数名应 一致 。名字应仅有 大小写的 区别:
public class EventLog { public EventLog(string logName){ this.LogName = logName; } public string LogName { get { ... } set { ... } } }
-
CONSIDER
:建议为构造函数中的每一个形参添加相应的 属性 。这样有助于调试时 记录日志 等操作。如果该参数是 引用 类型,则要斟酌是否要用属性暴漏。
-
DO
:在构造函数中做最少的工作。构造函数不应该执行太多操作,开销大的操作应该 推迟到具体调用的方法 执行。
-
DO
:实例构造函数 可以 抛出异常。构造函数抛出异常时,虽然 new 操作不会返回对象的引用,但事实上对象已经创建了。即使构造函数只完成了一半工作,GC 仍会对其进行垃圾回收,调用 Finalize 方法。
-
DO
:如果默认(无参)构造函数是必须的,应该 在类中显式地声明 。防止后期添加有参构造函数抑制编译器创建无参构造函数,最终导致已有代码无法运行。
-
AVOID
:struct 中 不要 显式定义默认构造函数。struct 的有参构造函数不会抑制编译器自动创建无参构造函数。使用编译器生成的无参构造函数创建数组速度更快。
其实这条原则和之前的 struct 属性设置为只读的相违背了。
-
AVOID
:避免在父类的构造函数中调用 内部虚 成员。以下例子便是子类尚未构造完成,父类(虚类)构造函数调用了内部虚成员:
// 以下代码在创建Derived实例时会打印“未完成构造”。 public abstract class Base { public Base() { Method(); } public abstract void Method(); } public class Dervied : Base { private int value; public Dervied() { value = 1; } public override void Method() { if(value == 1) Console.WriteLine("已完成构造"); else Console.WriteLine("未完成构造"); } }
类型构造函数的规范
-
DON'T
: 禁止 在静态构造函数中抛出异常。抛出异常意味着禁止在当前的应用程序域中 使用该类型 。
-
CONSIDER
:以 内联 的方式初始化static readonly
字段,而不是 显式定义静态构造函数 。runtime 会对 内联 字段进行性能优化。
注意:static 内联字段的初始化可能发生在 任何时候 (可能会提前,由 CLR 决定),而静态构造函数仅会在 第一个成员被访问 前运行,不会提前。
4 事件的设计
-
DO
:在事件中使用术语“ raise ”来描述事件的触发,而非“ fire ”、“ trigger ”。“ raise ”这个名字更为中性。
-
DO
:使用 System.EventHandler<T>
来定义事件委托,而非自定义委托。public class NotifyingContractCollection : Collection<Contact> { public event EventHandler<ContactAddedEventArgs> ContactAdded; }
-
CONSIDER
:优先用EventArgs
的 子类 ,而非EventArgs
做事件参数。如果直接使用
EventArgs
做事件参数,后期想传递数据势必会破坏 兼容性 。如果使用 子类 ,即使一开始是空的,后续仍可以扩展。
-
DO
:用 受保护 的 虚 方法触发事件(仅适用可派生类型),触发事件的方法应该带一个EventArgs
类型的参数,参数名称为 e 。这样派生类可以通过覆盖虚方法来处理事件。根据约定,方法的名字应该以“ On ”开头,随后是事件的名字:
public event EventHandler<AlarmRaisedEventArgs> AlarmRaised; protected virtual void OnAlarmRaised(AlarmRaisedEventArgs e) { var handler = AlarmRaised; if(handler != null) { handler(this, e); } }
本条示例代码中,定义了一个名为
handle
的变量,目的是防止AlarmRaised
在判断非空那一刻为 true,下一刻被其他线程设置为了 null 。
-
DON'T
:非静态事件禁止把 null 作为 sender 参数传入;静态事件必须把 null 作为 sender 参数传入。如果不想传递任何数据给事件处理程序,不要传入 null ,应传入
EventArgs.Empty
。EventHandler<EventArgs> handler = ... ; if (handler != null) handler(null, ...);
-
CONSIDER
: 前置 事件应可以取消 后置 事件的触发。可以用
Systme.ComponentModel.CancelEventArgs
或子类作为参数。例如:void ClosingHandler(object sender, CancelEventArgs e){ e.Cancel = true; }
自定义事件处理程序的设计
-
DO
:事件处理程序的返回类型应为 void
;用 object
作为第一个参数的类型,并命名为 sender
;用 System.EventArgs
或其子类作为第二个参数的类型,并命名为 e
。事件处理程序应有且仅有这两个参数。
5 字段的设计
-
DON'T
:实例字段不应该为 public 或 protected 。
-
DO
:永远不会改变的常量应该定义为 常量字段 。public struct Int32 { public const int MaxValue = 0x7fffffff; public const int MinValue = unchecked((int)0x80000000); }
-
DO
:预定义的对象实例,应该定义为类型的 public
、 static
、 readonly
字段/属性。public struct Color { public static readonly Color Red = new Color(0x0000FF); public static readonly Color Green = new Color(0x00FF00); public static readonly Color Blue = new Color(0xFF0000); }
预定义的静态只读字段的初始化会 一次性 完成,因此可能有 性能 问题;而静态只读属性,则可以 按需 构建,结合 JIT 内联调用,性能可以做到和字段相同。当预定义数据量较大,应考虑设置为只读 属性 。
-
DON'T
:不要将“浅度不可变”实例当作“深度不可变”实例暴漏给外侧。public class SomeType { public static readonly int[] Numbers = new int[10] } ... // 虽然数组被声明为readonly,但仍可改变其中的值。 SomeType.Numbers[5] = 10;
6 扩展方法
-
AVOID
:不要轻易定义扩展方法,尤其是为别人的类型定义扩展方法。大量使用扩展方法可能会 把类型的 API 搞乱 。
CONSIDER
:以下场景建议使用扩展方法:
-
为一个接口的所有实现提供相关的辅助功能,而且这些功能可以通过核心接口来表达。
-
当实例方法会引入不必要的依赖时,建议使用扩展方法。
例如
String
类型不应该对System.Uri
有依赖性,因此String
中不应该实现ToUri()
方法。更好的设计是定义如下扩展方法:public static System.Uri Uri.ToUri(this string str)
-
当泛型类型需要设计一个有部分类型限制的方法时,建议使用泛型扩展方法:
// 该集合类型没有类型限制 public class SomeCollection<T> { ... } // 非泛化的扩展类 public static class SomeCollectionComparisons { public static T GetMinValue<T> (this SomeCollection<T> source) where T : IComparable<T> { ... } }
除了用
where
进行约束,还可以限制特定泛型实例。如下代码限制仅有ReadOnlySpan<char>
可以调用:public static bool IsWhiteSpace(this ReadOnlySpan<char> span) { ... }
-
DO
:当扩展方法中的this
参数为null
时,要抛出 ArgumentNullException
异常。
-
CONSIDER
:建议合理命名持有其功能的扩展方法的类型。例如,用
Routing
代替[ExtendType]Extensions
。
-
CONSIDER
:如果被扩展的是 接口 ,且该扩展方法常用,扩展方法可以放在同名 namespace 中。除此以外,它应该放在它的特性所表示的 namespace 中,而非同名 namespace 中。
如Uri.ToUri(this string str)
应该放在System.Net
中,而非System
(String
所在 namespace)中。
-
AVOID
:扩展方法不应重名,即使在不同的 namespace 中。重名在调用时会存在二义性。当然,如果我们就是想 让开发者二选一 ,方法同名是可以的。
如果出现同名,我们可以通过 完全限定的 方式消除二义性:
// 定义 namespace A { public static class AExtensions { public static void ExtensionMethod(this Foo foo) { ... } } } namespace B { public static class BExtensions { public static void ExtensionMethod(this Foo foo) { ... } } } // 调用 using A; ... var someObj = ... // 调用AExtensions.ExtensionMethod someObj.ExtensionMethod(); // 为消除二义性,通过完全限定的方式调用BExtensions.ExtensionMethod B.BExtensions.ExtensionsMethod(someObj);
-
DO
:扩展方法应该在与其特性相关的 namespace 中定义。
-
CONSIDER
:当类型是泛型,其扩展方法的泛型参数类型更 严格 时,扩展方法可以和该类型同 namespace。public partial struct ReadOnlyMemory<T> { ... } public static partial class MemoryExtensions { public static ReadOnlyMemory<char> Trim(this ReadOnlyMemory<char> memory) { ... } }
该准则也适用于泛型限制的扩展方法
public partial class CollectionDisposer { public static void DisposeAll<T>(this List<T> list) where T : IDisposable { ... } }
7 运算符重载
-
CONSIDER
:如果一个类型让人感觉是 基本 类型,可以重载操作符,否则不要重载运算符。如
System.String
定义了operator==
和operator!=
-
DO
:表示数值的 struct 应该 重载运算符。比如
System.Decimal
。
-
DON'T
:不要为了耍酷去定义运算符重载。不要设计类似于 C++ 的输入、输出流操作符“>>”。
-
DO
:要对称地重载运算符。如果重载了
operator==
,应该同时重载 operator!=
;重载了operator<
,也应该重载 operator>
。诸如此类。
-
DO
:为每个重载过的操作符提供对应的方法,并用易于理解的名字来命名。许多编程语言不支持重载操作符。
2 类型转换运算符
-
DON'T
:除非 有明确的用户 需求,否则不要提供类型转换操作符。
-
DON'T
:不要在类型领域外定义转换运算符。例如
DateTime
中不应该包含long
转换为DateTime
的转换方式,这种情况最好是使用构造函数:public struct DateTime { public DateTime(long ticks) { ... } }
隐式转换:
-
DON'T
:如果类型转换会出现精度丢失,则 不要 提供隐式转换。 -
DON'T
:如果转换开销较大,则 不要 提供隐式转换运算符。 -
DON'T
:隐式转换 不应 抛出异常。
显式转换:
-
DO
:如果一个强制转换承诺无损,而当前转换有损,要抛出 System.InvalidCastException
异常。public static explicit operator RangedInt32(long value){ if(value < Int32.MinValue || value > Int32.MaxValue){ throw new InvalidCastException(); } return new RangedInt32((int)value, Int32.MinValue, Int32.MaxValue); }
3 比较运算符
-
DO
:仅为实现 IComparable<T>
的类型实现比较运算符。
-
DO
:要保证比较运算符的实现与 IComparable<T>
的实现一致。
-
DO
:要为实现operator<
和IEquatable<T>
的类型实现 operator<=
。
-
DO
:要为自定义的比较运算符返回 布尔值 。
-
DO
:要确保比较运算符与其相应的 数学属性 保持一致。- 传递性: 如果 \(a<b\) , \(b<c\) ,则 \(a<c\) ;
- 逆转性: 如果 \(a<b\) ,则 \(b>a\) ;
- 非自反性: 如果 \(a<a\) 是
false
,则 \(a>a\) 也是 false
; - 非对称性: 如果 \(a<b\) 是
true
,则 \(b<a\) 是 false
。
-
AVOID
:避免为不同的 类型 定义比较运算符。原因和该准则相同。
8 参数的设计
-
DO
:用类层次结构中最接近 基 类的类型作为参数类型。同时该类型应满足成员所需功能。
例如,假设有一个方法对集合进行遍历并打印,最好选择
IEnumerable<T>
作为参数,而非List<T>
或IList<T>
:public void WriteItemsToConsole(IEnumerable<object> items) { foreach (object item in items) { Console.WriteLine(item.ToString()); } }
-
DON'T
: 禁止 使用保留参数。如果将来需要更多参数,通过 重载 的方式实现,而非保留参数。
-
DON'T
: 不要 在公开暴漏的方法中使用指针、指针数组、多维数组。
此类参数难以正确使用,大多情况下都可以重新设计 API 避免此类参数。
-
DO
:out 类型参数应放在 最后 (不包括参数数组),即使它会造成参数顺序不一致。public struct DateTimeOffest { public static bool TryParse(string input, out DateTimeOffset result); public static bool TryParse(string input, IFormatProvider formatProvider, DateTimesStyles styles, out DateTimeOffset result); }
-
DO
:覆写成员、实现接口成员时,参数名应 一致 。// 覆写成员 public class Object { public virtual bool Equals(object obj) { ... } } public class String { // 正确 public override bool Equals(object obj) { ... } // 错误 public override bool Equals(object value) { ... } } // 实现接口成员 public interface IComparable<T> { int CompareTo(T other); } public class Nullable<T> : IComparable<Nullable<T>> { // 正确做法,接口方法中参数名为other public int CompareTo(Nullable<T> other) { ... } // 错误做法,此处的参数名应该为other public int CompareTo(Nullable<T> nullable) { ... } }
1 枚举和布尔参数之间的选择
-
DO
:若有两个及以上的bool
形参,应该使用 枚举 。以下方法用布尔值做形参就很不直观:
// 有两个形参用布尔值,不直观 Stream stream = File.Open("foo.txt", true, false); // 使用枚举,直观 Stream stream = File.Open("foo.txt", CasingOptions.CaseSensitive, FileMode.Open);
-
DON'T
:除非 100% 确定 不会有两个以上的选项 ,不要使用bool
形参。枚举 有拓展性,而
bool
值没有。
-
CONSIDER
:在构造函数中为成员赋值时,对确实只有两种状态的参数,且属性类型是bool
,使用 bool
形参。一个属性,如果常用构造函数来设置,那么构造函数用 枚举 较好;如果常用
setter
设置,则构造函数用 bool
较好。
2 参数的验证
-
DO
:对public
、protected
或显式实现的成员,传入的参数应该进行验证。如果验证失败,应该抛出 System.ArgumentException
或其子类。public class StringCollection : IList { int IList.Add(object item) { string str = item as string; if (str == null) throw new ArgumentException(...); return Add(str); } }
-
DO
:如果不支持null
,应该抛出 ArgumentNullException
异常。
-
DO
:验证枚举参数。但不要用 Enum.IsDefined
来检查枚举范围。CLR 允许用户把任何整数强制转换为枚举值,即使该值在枚举中未定义,因此要进行检验。
Enum.IsDefined
通过 反射 检查,开销非常高,不推荐使用。public void PickColor(Color color) { if (color > Color.Black || color < Color.White) { throw new ArgumentOutOfRangeException(...); } }
-
DO
:要意识到引用类型可能会在验证后发生 改变 。如果该成员涉及安全性,最好用它的副本进行验证和处理参数。
3 参数的传递(out 和 ref)
-
AVOID
: 避免 使用 out 参数或 ref 参数。这两种参数涉及指针、值类型和引用类型的区别,有较高的使用难度。如果框架的目标用户很广泛,则不应该期望用户可以正确使用这两种参数。
-
DON'T
: 不要 通过引用方式(ref)传递引用类型。仅有几种情况例外,比如进行 交换引用 :
public static class Reference { public void Swap<T>(ref T obj1, ref T obj2){ T temp = obj1; obj1 = obj2; obj2 = temp; } }
4 参数数量可变的成员
-
CONSIDER
:作为参数的数组,若数组元素较 少 ,可以使用参数数组(params)。如果传入的数组元素 本来已经在一个数组中了 ,不要使用 params。如果传入的元素很多,用户一般不会用内联的方式传递这些元素,也就没有必要使用 params。
比如 byte 数组,它的数据一般很多,且没人会对 byte[]挨个赋值,没有必要使用 params。
-
CONSIDER
:简单的重载方法 可以 使用 params,即使复杂重载不能用 params。对参数进行合理排序,以便使用 params 关键字。以下重载方法,假设第一个很常用,加上 params 对于用户来说会方便很多。
public class Graphics { FillPolygon(Brush brush, params Point[] points) { ... } FillPolygon(Brush brush, Point[] points, FillMode fillMode) { ... } }
-
DON'T
:如果方法内部要对数组进行 修改 ,不要使用 params 参数数组。params 参数数组是 临时 数组,是一个 临时 对象,对于它的任何修改都会丢失。
-
CONSIDER
:如果 API 对性能要求非常高,应该添加 参数数量较少的 重载方法及相应实现。因 params 会创建 临时数组 导致性能损失,如果 API 对性能要求非常高,则要单独实现这些方法。实现不能是对 params 方法的简单调用。参数名也添加相应的数字后缀:
void Format(string formatString, object arg1) { ... } void Format(string formatString, object arg1, arg2) { ... } void Format(string formatString, params object[] args) { ... }
-
DO
:要验证传入的 params 数组是否为 null 。static void Main(){ Sum(1, 2, 3, 4, 5); Sum(null); } static int Sum(params int[] values){ if (values == null) throw new ArgumentNullException(...); ... }
5 指针参数
-
DO
: 要 为指针参数的成员提供替补成员。
指针不符合 CLS 规范。[CLSCompliant(false)] public unsafe int GetBytes(char* chars, int charCount, byte* bytes, int byteCount); public int GetBytes(char[] chars, int charIndex, int charCount, byte[] bytes, int byteIndex, int byteCount)
-
AVOID
: 不要 对指针参数进行高开销的检查。如果 API 对性能的要求都到了必须使用指针的地步,检查参数造成的性能损失将不可接受。
-
DO
:指针成员的设计要遵循指针的常用约定。例如,在参数中传递起始索引是不必要的,我们可以通过简单的指针运算得到相同的结果:
// 坏实现 public unsafe int GetBytes(char* chars, int charIndex, int charCount, byte* bytes, int byteIndex, int byteCount); // 好实现 public unsafe int GetBytes(char* chars, int charCount, byte* bytes, int byteCount); // 调用举例 GetBytes(chars + charIndex, charCount, bytes + byteIndex, byteCount);
标签:...,DO,int,成员,参数,设计,public,构造函数 From: https://www.cnblogs.com/hihaojie/p/18663024/chapter-5-member-design-ntpe7