首页 百科知识 每一个#开发者都必须知道的件事情

每一个#开发者都必须知道的件事情

时间:2022-09-18 百科知识 版权反馈
【摘要】:程序的Bug与瑕疵往往出现于开发流程当中。避免使用ICloneable接口——开发者从来没搞清楚一个被复制的对象到底是深拷贝还是浅拷贝。尽量避免向结构体中进行写入,将它们视为一种不变的对象以防止混乱。永远不要让属性getter出现异常,并且也要避免修改对象状态。你可以为一个新创建的对象根据它创建的表达形式赋予属性。例如为Foo与Bar属性创建一个新的具有给定值的C类对象:初始化过程在构造函数体

程序的Bug与瑕疵往往出现于开发流程当中。只要对工具善加利用,就有助于在你发布程序之前便将问题发现,或避开这些问题。

标准化代码书写

    标准化代码书写可以使代码更加易于维护,尤其是在代码由多个开发者或团队进行开发与维护时,这一优点更加突出。常见的强制代码规范化的工具有:FxCop、StyleCop和ReSharper。

    开发者语:在掩盖错误之前请仔细地思考这些错误,并且去分析结果。不要指望依靠这些工具来在代码中寻找错误,因为结果可能和你的与其相去甚远。

    审查代码与搭档编程都是很常见的练习,比如开发者刻意去审查他人书写的代码。而其他人很希望发现代码开发者的一些bug,例如编码错误或者执行错误。

    审查代码是一种很有价值的练习,由于很依赖于人工操作,因此很难被量化,准确度也不够令人满意。

静态分析

    静态分析不需要你去运行代码,你不必编写测试案例就可以找出一些代码不规范的地方,或者是一些瑕疵的存在。这是一种非常有效地寻找问题的方式,但是你需要有一个不会有太多误报问题的工具。C#常用的静态分析工具有Coverity,CAT,NET,Visual Studio Code Analysis。

动态分析

    在你运行代码的时候,动态分析工具可以帮你找出这些错误:安全漏洞,性能与并发性问题。这种方法是在执行时期的环境下进行分析,正因如此,其有效性便受制于代码复杂度。Visual Studio提供了包括Concurrency Visualizer, IntelliTrace, and Profiling Tools在内的大量动态分析工具。

    管理者/团队领导语:开发实践是练习规避常见陷阱的最好方法。同时也要注意测试工具是否符合你的需求。尽量让你团队的代码诊断水平处于可控的范围内。

测试

    测试的方式多种多样:单元测试,系统集成测试,性能测试,渗透测试等等。在开发阶段,绝大多数的测试案例是由开发者或测试人员来完成编写,使程序可以满足需求。

    测试只在运行正确的代码时才会有效。在进行功能测试的时候,它还可以用来挑战开发者的研发与维护速度。

开发最佳实践

    工具的选择上多花点时间,用正确的工具去解决你关心的问题,不要为开发者增添额外的工作。让分析工具与测试自动流畅地运行起来去寻找问题,但是要保证代码的思想仍然清晰地留在开发者的头脑当中。

    尽可能快地定位诊断出来的问题所在位置(不论是通过静态分析还是测试得到的错误,比如编译警告,标准违例,问题检测等)。如果刚出来的问题由于“不关心”而去忽略它,导致该问题后来很难找到,那么就会给代码审阅工作者增加很大的工作量,并且还要祈祷他们不会因此烦躁。

    请接受这些有用的建议,让自己代码的质量,安全性,可维护性得到提升,同时也提升开发者们的研发能力、协调能力,以及提升发布代码的可预测性。

    C#的一个主要的优点就是其灵活的类型系统,而安全的类型可以帮助我们更早地找到错误。通过强制执行严格的类型规则,编译器能够帮助你维持良好的代码书写习惯。在这一方面,C#语言与.NET框架为我们提供了大量的类型,以适应绝大多数的需求。虽然许多开发者对一般的类型有着良好的理解,并且也知晓用户的需求,但是一些误解与误用仍然存在。

理解并使用标准接口

    特定的接口涉及到常用的C#特征。例如,IDiposable允许使用常见的资源管理语言,例如关键词“using”。良好地理解接口可以帮助你书写通顺的C#代码,并且更易于维护。

    避免使用ICloneable接口——开发者从来没搞清楚一个被复制的对象到底是深拷贝还是浅拷贝。由于仍没有一种对复制对象操作是否正确的标准评判,于是也就没办法有意义地去将接口作为一个contract去使用。

结构体

    尽量避免向结构体中进行写入,将它们视为一种不变的对象以防止混乱。在像多线程这种场景下进行内存共享,会变得更安全。我们对结构体采用的方法是,在创建结构体时对其进行初始化操作,如果需要改变其数据,那么建议生成一个新的实体。

    正确理解哪些标准类型/方法是不可变,并且可返回新的值(例如串,日期),用这些来替代那些易变对象(如List.Enumerator)。

    字符串的值可能为空,所以可以在合适的时候使用一些比较方便的功能。值判断(s.Length==0)时可能会出现NullReferenceException错误,而String.IsNullOrEmpty(s)和String.IsNullOrWhitespace(s)可以很好地使用null。

    枚举类型与常量可以使代码更加易于阅读,通过利用标识符替换幻数,可以表现出值的意义。如果你需要生成大量的枚举类型,那么带有标记的枚举类型是一种更加简单的选择。

    注意无类型容器在重载方面的影响,可以考虑使用“myArrayList[0] == myString”这一方法。数组元素是编译阶段类型的“对象”,因此引用相等性可以使用。虽然C#会向你提醒这些潜在的错误,但是在编译过程中,unexpected reference equality在某些情况下不会被提醒。

封装你的数据

    类在恰当管理数据方面起很大的作用。鉴于性能上的一些原因,类总是缓存部分结果,或者是在内部数据的一致性上做出一些假设。使数据权限公开的话会在一定程度上让你去缓存,或者是作出假设,而这些操作是通过对性能、安全性、并发性的潜在影响表现出来的。例如暴露像泛型集合、数组之类的易变成员项,可以让用户跳过你而直接进行结构体的修改。

属性

    除了可以通过access modifiers控制对象之外,属性还可以让你很精确地掌控用户与你的对象之间进行了什么交互。特别要指出的是,属性还可以让你了解到读写的具体情况。

    属性能在通过存储逻辑将数据覆写进getters与setters的时候帮助你建立一个稳定的API,或是提供一个数据的绑定资源。

    永远不要让属性getter出现异常,并且也要避免修改对象状态。这是一种对方法的需求,而不是属性的getter。

对象初始化

    你可以为一个新创建的对象根据它创建的表达形式赋予属性。例如为Foo与Bar属性创建一个新的具有给定值的C类对象:

    你也可以生成一个具有特定属性名称的匿名类型的实体:

    初始化过程在构造函数体之前运行,因此需要保证在输入至构造函数之前,将这一域给初始化。由于构造函数还没有运行,所以目标域的初始化可能不管怎样都不涉及“this”。

过渡规范细化的输入参数

    为了使一些特殊方法更加容易控制,最好在你使用的方法当中使用最少的特定类型。比如在一种方法中使用 List<Bar>进行迭代。对于其他IEnumerable<Bar>集来说,使用这种方法的表现更加出色一些,但是对于特定的参数List<Bar>来说,我们更需要使集以表的形式表现。尽量少地选取特定的类型(诸如IEnumerable<T>, ICollection<T>此类)以保证你的方法效率的最大化。

    泛型是一种在定义独立类型结构体与设计算法上一种十分有力的工具,它可以强制类型变得安全。

    用像List<T>这样的泛型集来替代数组列表这种无类型集,既可以提升安全性,又可以提升性能。

    在使用泛型时,我们可以用关键词“default”来为类型获取缺省值(这些缺省值不可以硬编码写进implementation)。特别要指出的是,数字类型的缺省值是o,引用类型与空类型的缺省值为null。

    类型转换有两种模式。其一显式转换必须由开发者调用,另一隐式转换是基于环境下应用于编译器的。

    常量o可由隐式转换至枚举型数据。当你尝试调用含有数字的方法时,可以将这些数据转换成枚举类型。

转换通常意味着以下两件事之一:

1、RuntimeType的表现可比编译器所表现出来的特殊的多,Cast转换命令编译器将这种表达视为一种更特殊的类型。如果你的设想不正确的话,那么编译器会向你输出一个异常。例如:将对象转换成串。

2、有一种完全不同的类型的值,与Expression的值有关。Cast命令编译器生成代码去与该值相关联,或者是在没有值的情况下报出一个异常。例如:将double类型转换成int类型。

    以上两种类型的Cast都有着风险。第一种Cast向我们提出了一个问题:“为什么开发者能很清楚地知道问题,而编译器为什么不能?”如果你处于这个情况当中,你可以去尝试改变程序让编译器能够顺利地推理出正确的类型。如果你认为一个对象的runtime type是比compile time type还要特殊的类型,你就可以用“as”或者“is”操作。

    第二种cast也提出了一个问题:“为什么不在第一步就对目标数据类型进行操作?”如果你需要int类型的结果,那么用int会比double更有意义一些。

    在某些情况下显式转换是一种正确的选择,它可以提高代码可阅读性与debug能力,还可以在采用合适的操作的情况下提高测试能力。

异常并不是condition

    异常不应该常出现在程序流程中。它们代表着开发者所不愿看到的运行环境,而这些很可能无法修复。如果你期望得到一个可控制的环境,那么主动去检查环境会比等待问题的出现要好得多。

    利用TryParse()方法可以很方便地将格式化的串转换成数字。不论是否解析成功,它都会返回一个布尔型结果,这要比单纯返回异常要好很多。

注意使用exception handling scope

    写代码时注意catch与finally块的使用。由于这些不希望得到的异常,控制可能进入这些块中。那些你期望的已执行的代码可能会由于异常而跳过。如:

    如果GetCurrentFrobber()报出了一个异常,那么当finally blocks被执行时originalFrobber的值仍然为空。如果GetCurrentFrobber不能被扔掉,那么为什么其内部是一个try block?

    要注意有针对性地处理你的目标异常,并且只去处理目标代码当中的异常部分。尽量不要去处理所有异常,或者是根类异常,除非你的目的是记录并重新处理这些异常。某些异常会使应用处于一种接近崩溃的状态,但这也比无法修复要好得多。有些试图修复代码的操作可能会误使情况变得更糟糕。

    关于致命的异常都有一些细微的差异,特别是注重finally blocks的执行,可以影响到异常的安全与调试。

    使用一款顶级的异常处理器去安全地处理异常情况,并且会将debug的一些问题信息暴露出来。使用catch块会比较安全地定位那些特殊的情况,从而安全地解决这些问题,再将一些问题留给顶级的异常处理器去解决。

    如果你发现了一个异常,请做些什么去解决它,而不要去将这个问题搁置。搁置只会使问题更加复杂,更难以解决。

    将异常包含至一个自定义异常中,对面向公共API的代码特别有用。异常是可视界面方法的一部分,它也被参数与返回值所控制。但这种扩散了很多异常的方法对于代码的鲁棒性与可维护性的解决来说十分麻烦。

    如果你希望在更高层次上解决caught异常,那么就维持原异常状态,并且栈就是一个很好的debug方法。但需要注意维持好debug与安全考虑的平衡。

    有些异常发生于你代码的运行环境之外。与其使用caught块,你可能更需要向目标当中添加如ThreadException或UnhandledException之类的处理器。例如,Windows窗体异常并不是出现于窗体处理线程环境当中的。

原子性(数据完整性)

    千万不要让异常影响到你数据模型的完整性。你需要保证你的对象处于比较稳定的状态当中——这样一来任何由类的执行的操作都不会出现违例。否则,通过“恢复”这一手段会使你的代码变得更加让人不解,也容易造成进一步的损坏。

    考虑几种修改私有域顺序的方法。如果在修改顺序的过程当中出现了异常,那么你的对象可能并不处于非法状态下。尝试在实际更新域之前去得到新的值,这样你就可以在异常安全管理下,正常地更新你的域。

    对特定类型的值——包括布尔型,32bit或者更小的数据类型与引用型——进行可变量的分配,确保可以是原子型。没有什么保障是给一些大型数据(double,long,decimal)使用的。可以多考虑这个:在共享多线程的变量时,多使用lock statements。

7、事件

    事件与委托共同提供了一种关于类的方法,这种方法在有特殊的事情发生时向用户进行提醒。委托事件的值在事件发生时应被调用。事件就像是委托类型的域,当对象生成时,其自动初始化为null。

    事件也像值为“组播”的域。这也就是说,一种委托可以依次调用其他委托。你可以将一个委托分配给一个事件,你也可以通过类似-=于+=这样的操作来控制事件。

    如果一个事件被多个线程所共享,另一个线程就有可能在你检查是否为null之后,在调用其之前而清除所有的用户信息——并抛出一个NullReferenceException。

    对于此类问题的标准解决方法是创建一个该事件的副本,用于测试与调用。你仍然需要注意的是,如果委托没有被正确调用的话,那么在其他线程里被移除的用户仍然可以继续操作。你也可以用某种方法将操作按顺序锁定,以避免一些问题。

    不要忘记将事件处理器Unhook使用一种事件处理器为事件资源生成一个由处理器的资源对象到接收对象的引用,可以保护接收端的garbage collection。

    适当的unhook处理器可以确保你不必因委托不再工作而去调用它浪费时间,也不会使内存存储无用委托与不可引用的对象。

8、属性

    属性提供了一种向程序集、类与其信息属性中注入元数据的方法。它们经常用来提供信息给代码的消费者——比如debugger、框架测试、应用——通过反射这一方式。你也可以向你的用户定义属性,或是使用预定义属性,详见下表:

    一定要对DebuggerStepThrough多重视几分——否则它会在这个方法应用的地方让寻找bug变得十分困难,你也会因此而跳过某步或是推倒而重做它。

9、Debug

    Debug是在开发过程中必不可少的部分。除了使运行环境不透明的部分变得可视化之外,debugger也可以侵入运行环境,并且如果不使用debugger的话会导致应用程序变现有所不同。

使异常栈可视化

    为了观察当前框架异常状态,你可以将“$exception”这一表达添加进Visual Studio Watch窗口。这种变量包含了当前异常状态,类似于你在catch block中所看见的,但其中不包含在debugger中看见的不是代码中的真正存在的异常。

你第一次在debugger中看见这个对象时,remainingAccesses会获得一个值为10的整型变量,并且MeteredData为null。然而如果你hover结束了remainingAccesses,你会发现它的值会变成9.这样一来debugger的属性值表现改变了你的对象的状态。

10、性能优化

早做计划,不断监测,后做优化

    在设计阶段,制定切实可行的目标。在开发阶段,专注于代码的正确性要比去做微调整有意义的多。对于你的目标,你要在开发过程中多进行监测。只需要在你没有达到预期的目标的时候,你才应该去花时间对程序做一个调整。

    请记住用合适的工具来确保性能的经验性测量,并且使测试处于这样一种环境当中:可反复多次测试,并且测试过程尽量与现实当中用户的使用习惯一致。

    当你对性能进行测试的时候,一定要注意你真正所关心的测试目标是什么。在进行某一项功能的测试时,你的测试有没有包含这项功能的调用或者是回路构造的开销?

    我们都听说过很多比别人做得快很多的项目神话,不要盲目相信这些,试验与测试才是实在的东西。

    由于CLR优化的原因,有时候看起来效率不高的代码可能会比看起来效率高的代码运行的更快。例如,CLR优化循环覆盖了一个完整的数组,以避免在不可见的per-element范围里的检查。开发者经常在循环一个数组之前先计算一下它的长度:

    通过将长度存储进一个变量当中,CLR会不去识别这一部分,并且跳过优化。但是有时手动优化会反人类地导致更糟糕的性能表现。

    如果你打算将大量的字符串进行连接,可以使用System.Text.StringBuilder来避免生成大量的临时字符串。

    如果你打算生成并填满集合中已知的大量数据,由于再分配的存在,可以用保留空间来解决生成集合的性能与资源问题。你可以用AddRange方法来进一步对性能进行优化,如下在List<T>中处理:Persons.AddRange(listBox.Items);

11、资源管理

    利用try/finally block来确保资源已被合理释放,或是让你的类使用IDisposable,以及更方便更安全的声明方式。

    除了用调用GC.Collect()干扰garbage collector之外,也可以考虑适当地释放或是抛弃资源。在进行性能测试时,如果你可以承担这种影响带来的后果,你再去使用garbage collector。

    与当前一些流传的谣言不同的是,你的类不需要Finalizers,而这只是因为IDisposable的存在!你可以让IDisposable赋予你的类在任何已拥有的组合实例中调用Dispose的能力,但是finalizers只能在拥有未管理的资源类中使用。

    Finalizers主要对交互式Win32位句柄API有很大作用,并且SafeHandle句柄是很容易利用的。

    不要总是设想你的finalizers(总是在finalizer线程上运行的)会很好地与其他对象进行交互。那些其他的对象可能在该进程之前就被终止掉了。

    处理并发性与多线程编程是件复杂的、困难的事情。在将并发性添加进你的程序之前,请确保你已经明确了解你的做的是什么——因为这里面有太多门道了!

    多线程软件的情况很难进行预测,比如很容易产生如竞争条件与死锁的问题,而这些问题并不是仅仅影响单线程应用。基于这些风险,你应该将多线程视为最后一种手段。如果不得不使用多线程,尽量缩减多线程同时使用内存的需求。如果必须使线程同步,请尽可能地使用最高等级的同步机制。在最高等级的前提下,包括了这些机制:

Async-await/Task Parallel Library/Lazy<T>

Lock/monitor/AutoResetEvent

Interlocked/Semaphore

可变域与显式barrier

    以上的这些很难解释清楚C#/.NET的复杂之处。如果你想开发一个正常的并发应用,可以去参阅O’Reilly的《Concurrency in C# Cookboo》。

    将一个域标记为“volatile”是一种高级特性,而这种设置也经常被专家所误解。C#的编译器会保证目标域可以被获取与释放语义,但是被lock的域就不适用于这种情况。如果你不知道获取什么,不知道释放什么语义,以及它们是怎样影响CPU层次的优化,那么久避免使用volatile域。取而代之的可以用更高层次的工具,比如Task Parallel Library或是CancellationToken。

    标准库类型常提供使对象线程安全更容易的方法。例如Dictionary.TryGetValue()。使用此类方法一般可以使你的代码变得更加清爽,并且你也不必担心像TOCTOU(time-of-check-time-of-use竞争危害的一种)这样的数据竞争。

不要锁住“this”、字符串,或是其他普通public的对象

    当使用在多线程环境下的一些类时,多注意lock的使用。锁住字符串常量,或是其他公共对象,会阻止你锁状态下的封装,还可能会导致死锁。你需要阻止其他代码锁定在同一使用的对象上,当然你最好的选择是使用private对象成员项。

13、避免常见的错误

    静态与动态分析工具可以在你发布代码之前为你检查出潜在的NullReference Exception。在C#当中,引用型为null通常是由于变量没有引用到某个对象而造成的。对于值可为空的类型与引用型来说,是可以使用null的。例如:Nullable <Int>,空委托,已注销的事件,“as”转化失败的,以及一些其他的情况。

    每个null引用异常都是一个bug。相比于找到NullReferenceException这个问题来说,不如尝试在你使用该对象之前去为null进行测试。这样一来可以使代码更易于最小化的try/catch block读取。

    当从数据库表中读取数据时,注意缺失值可以表示为DBNull 对象,而不是作为空引用。不要期望它们表现得像潜在的空引用一样。

二进制的数字表示十进制的值

    Float与double都可以表示十进制实数,但不能表示二进制实数,并且在存储十进制值的时候可以在必要时用二进制的近似值存储。从十进制的角度来看,这些二进制的近似值通常都有不同的精度与取舍,有时在算数操作当中会导致一些不期望的结果。由于浮点型运算通常在硬件当中执行,因此硬件条件的不可预测会使这些差异更加复杂。

非预期计算

    C#编译器可以保护在运算过程中的常量溢出,但不一定是计算值。使用 “checked” 与“unchecked”两个关键词来标记你想对变量进行什么操作。

    与结构体不同的是,类是引用类型,并且可以适当地修改引用对象。然而并不是所有的对象方法都可以实际修改引用对象,有一些返回的是一个新的对象。当开发者调用后者时,他们需要记住将返回值分配给一个变量,这样才可以使用修改过的对象。在代码审查阶段,这些问题的类型通常会逃过审查而不被发现。像字符串之类的对象,它们是不可变的,因此永远不可能修改这些对象。即便如此,开发者还是很容易忘记这些问题。

例如,看如下 string.Replace()代码:

    这两行代码运行之后会打印出“My name is Aloysius” ,这是因为Raeplace方法并没改变该字符串的值。

不要使迭代器与枚举器失效

    注意不要在遍历时去修改集合

    如果你运行了这个代码,那么它一在下一项的集合中进行循环,你就会得到一个异常。

属性名称错误

    在实现属性时,要注意属性的名称和在类当中用的成员项的名字有很大差别。很容易在不知情的情况下使用了相同的名称,并且在属性被获取的时候还会触发死循环。

    在重命名间接属性时同样要小心。例如:在WPF中绑定的数据将属性名称指定为字符串。有时无意的改变属性名称,可能会不小心造成编译器无法解决的问题。

来源网址:

免责声明:以上内容源自网络,版权归原作者所有,如有侵犯您的原创版权请告知,我们将尽快删除相关内容。

我要反馈