C#或.NET中最糟糕的陷阱是什么?

384

我最近在使用一个DateTime对象,并写了类似这样的代码:

DateTime dt = DateTime.Now;
dt.AddDays(1);
return dt; // still today's date! WTF?

AddDays() 的智能感知文档称它会将一天添加到日期上,但实际上它只是返回一个将一天添加后的日期,所以你需要这样写:

DateTime dt = DateTime.Now;
dt = dt.AddDays(1);
return dt; // tomorrow's date

这个问题之前已经让我多次吃过亏,因此我认为记录最严重的C#陷阱会很有用。


158
返回 DateTime.Now 增加一天后的日期。 - crashmstr
24
据我所知,内置值类型都是不可变的,至少在该类型包含的任何方法中,返回的都是一个新项,而不是修改现有项。至少,我想不到有一个不这样做的(方法):所有这些行为都非常一致。 - Joel Coehoorn
6
可变值类型:System.Collections.Generics.List.Enumerator。 (如果你尝试得足够努力,你会看到它表现得很奇怪。) - Jon Skeet
14
智能感知会为你提供所需的所有信息。它表明返回一个DateTime对象。如果它只是改变了你传入的对象,那么它就是一个void方法。 - John Kraft
22
不一定:例如,StringBuilder.Append(...)返回“this”,这在流畅接口中非常常见。 - Jon Skeet
显示剩余10条评论
61个回答

309
private int myVar;
public int MyVar
{
    get { return MyVar; }
}

该应用程序经常崩溃且没有堆栈跟踪。

(请注意getter中使用大写MyVar而不是小写myVar。)


114
适合于此网站 :) - gbjbaanb
64
我在私有成员前加了下划线,帮助很大! - chakrit
61
我会尽可能使用自动属性,这样可以避免很多这样的问题 ;) - TWith2Sugars
30
使用前缀来命名私有字段是一个非常好的做法(除此之外还有其他原因),这是其中一个很好的原因:_myVar,m_myVar。 - jrista
209
@jrista:哦,拜托不要用m_......啊,太可怕了...... - fretje
显示剩余14条评论

257

Type.GetType

我见过很多人都会遇到问题的一个是Type.GetType(string)方法。他们想知道为什么它可以用于自己程序集中的类型和像System.String这样的类型,但不能用于System.Windows.Forms.Form。答案是该方法只会查找当前程序集和mscorlib


匿名方法

C# 2.0引入了匿名方法,导致出现了一些麻烦的情况,比如:

using System;
using System.Threading;

class Test
{
    static void Main()
    {
        for (int i=0; i < 10; i++)
        {
            ThreadStart ts = delegate { Console.WriteLine(i); };
            new Thread(ts).Start();
        }
    }
}

那会打印出什么?这完全取决于调度。它将打印10个数字,但它可能不会打印0、1、2、3、4、5、6、7、8、9,这不是你所期望的。问题在于被捕获的是变量i,而不是在创建委托时变量i的值。可以通过增加一个正确作用域的额外局部变量来轻松解决这个问题:

using System;
using System.Threading;

class Test
{
    static void Main()
    {
        for (int i=0; i < 10; i++)
        {
            int copy = i;
            ThreadStart ts = delegate { Console.WriteLine(copy); };
            new Thread(ts).Start();
        }
    }
}

迭代器块的延迟执行

这个“穷人版单元测试”为什么不能通过?

using System;
using System.Collections.Generic;
using System.Diagnostics;

class Test
{
    static IEnumerable<char> CapitalLetters(string input)
    {
        if (input == null)
        {
            throw new ArgumentNullException(input);
        }
        foreach (char c in input)
        {
            yield return char.ToUpper(c);
        }
    }
    
    static void Main()
    {
        // Test that null input is handled correctly
        try
        {
            CapitalLetters(null);
            Console.WriteLine("An exception should have been thrown!");
        }
        catch (ArgumentNullException)
        {
            // Expected
        }
    }
}
答案:

答案是,CapitalLetters 代码中的代码直到迭代器的 MoveNext() 方法被首次调用才会执行。

我在脑筋急转弯页面 上有一些其他奇怪的问题。


26
迭代器的例子很狡猾! - Jimmy
9
为什么不将这个问题拆分成3个回答,这样我们就可以分别投票,而不是一起投票呢? - chakrit
13
回顾过去,那可能是个好主意,但我认为现在已经太晚了。而且这样做可能会让人觉得我只是想获取更多声望值。 - Jon Skeet
19
如果提供AssemblyQualifiedName,Type.GetType函数是有效的。 示例:Type.GetType("System.ServiceModel.EndpointNotFoundException, System.ServiceModel, Version=3.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"); - chilltemp
2
@FosterZ:它正在创建一个类型为ThreadStart的委托,该委托将当前i的值打印到控制台。 - Jon Skeet
显示剩余14条评论

201

海森堡观察窗口

如果你正在进行类似于以下示例的按需加载等操作,那么这可能会对你造成严重影响:

private MyClass _myObj;
public MyClass MyObj {
  get {
    if (_myObj == null)
      _myObj = CreateMyObj(); // some other code to create my object
    return _myObj;
  }
}

现在假设你有其他地方使用这段代码:

// blah
// blah
MyObj.DoStuff(); // Line 3
// blah
现在你想要调试你的 CreateMyObj() 方法。所以你在上面第3行设置了一个断点,打算逐步执行代码。为了保险起见,你还在上一行说_myObj = CreateMyObj();的地方设置了一个断点,甚至在 CreateMyObj() 内部也设置了一个断点。
代码命中了第3行的断点。你逐步执行代码。你期望进入条件代码,因为 _myObj 显然是 null,对吧?嗯...那么...为什么它跳过了条件而直接进入了 return _myObj ?! 你将鼠标悬停在 _myObj 上...确实,它有一个值!这是怎么发生的?
答案是,你的 IDE 引起了它的值变化,因为你打开了“监视”窗口,特别是“自动”监视窗口,它显示与当前或上一行执行相关的所有变量/属性的值。当你在第3行处触发断点时,监视窗口决定你会对 MyObj 的值感兴趣-因此,在不考虑任何断点的情况下,它计算了 MyObj 的值,包括设置 _myObj 值的 CreateMyObj() 调用!
这就是为什么我称之为海森堡观察窗口-你不能在不影响它的情况下观察其值... :)
注意:@ChristianHayter的评论提供了一个对这个问题的有效解决办法,所以每当你有一个惰性加载的属性时,装饰你的属性使用 [DebuggerBrowsable(DebuggerBrowsableState.Never)] 或者 [DebuggerDisplay("<loaded on demand>")]。

11
太棒了!你不是一个程序员,而是一个真正的调试专家。 - this. __curious_geek
26
即使是在悬停变量时,我也遇到了这个问题,而不仅仅是在监视窗口中。 - Richard Morgan
35
使用 [DebuggerBrowsable(DebuggerBrowsableState.Never)][DebuggerDisplay("<loaded on demand>")] 装饰您的属性。 - Christian Hayter
4
如果您正在开发框架类并希望拥有监视窗口功能,同时又不想改变懒构造属性的运行时行为,您可以使用调试器类型代理来返回已经被构造的值,并提供一个消息说明该属性尚未被构造。在Lazy<T>类中的Value属性就是其中一个使用这种方法的例子。 - Sam Harwell
4
我记得有个人在ToString的重载方法中改变了对象的值,但我不知道他为什么这么做。每次他将鼠标悬停在对象上时,提示框都会显示一个不同的值,他无法理解发生了什么。 - JNF
显示剩余2条评论

195

重新抛出异常

许多新开发者会遇到的难点是重新抛出异常语义。

我经常看到以下代码

catch(Exception e) 
{
   // Do stuff 
   throw e; 
}

问题在于它清除了堆栈跟踪信息,导致诊断问题更加困难,因为无法跟踪异常的起源。正确的代码应该是使用没有参数的throw语句:
catch(Exception)
{
    throw;
}

或者将异常包装在另一个异常中,并使用内部异常来获取原始的堆栈跟踪:

catch(Exception e) 
{
   // Do stuff 
   throw new MySpecialException(e); 
}

非常幸运,我在第一周得到了一位开发者的指导,并在更高级别的开发者的代码中找到了它。是这个吗: catch()
{
throw;
} 与下面的代码段相同吗? catch(Exception e)
{
throw;
} 只是它不会创建并填充异常对象?
- StuperUser
除了使用 throw ex (或 throw e) 而不是只使用 throw 的错误之外,我必须想知道,在什么情况下值得捕获异常,然后再次抛出它。 - Ryan Lundy
13
有很多情况需要这样做,例如,如果你想在调用者收到异常之前回滚一个事务。你可以先回滚,然后再抛出异常。 - R. Martinho Fernandes
7
我经常看到人们捕获并重新抛出异常,只是因为他们被教导必须捕获所有异常,却没有意识到它将在调用堆栈的更高层次被捕获。 这让我很生气。 - James Westgate
5
@Kyralessa 最常见的情况是你需要进行日志记录。在 catch 中记录错误,并重新抛出异常。 - nawfal
重新抛出异常不仅适用于.NET,而且也适用于Java。的确,省略变量并简单地throw是正确的。 - Gargravarr

145

这是另一个让我困惑的时间问题:

static void PrintHowLong(DateTime a, DateTime b)
{
    TimeSpan span = a - b;
    Console.WriteLine(span.Seconds);        // WRONG!
    Console.WriteLine(span.TotalSeconds);   // RIGHT!
}

TimeSpan.Seconds是时间跨度的秒部分(2分钟和0秒的秒值为0)。

TimeSpan.TotalSeconds是以秒为单位测量的整个时间跨度(2分钟的总秒数值为120)。


1
是的,那个也让我困惑了。我认为应该改成 TimeSpan.SecondsPart 或者其他更清晰地表示它代表什么的名称。 - Dan Diplo
3
重新阅读这段内容,我不禁想知道为什么TimeSpan需要一个Seconds属性。谁会在意时间跨度中的秒数呢?它是一个任意的、单位相关的值;我无法想象出任何实际用途。 - MusiGenesis
2
我认为TimeSpan.TotalSeconds返回时间跨度中总秒数是有意义的。 - Ed S.
16
@MusiGenesis 这个属性很有用。如果我想将时间跨度分解成各个部分显示怎么办?例如,假设您的时间跨度代表了“3小时15分钟10秒”的持续时间。如何在没有“秒”,“小时”和“分钟”属性的情况下访问这些信息? - SolutionYogi
1
在类似的API中,我使用了“SecondsPart”和“SecondsTotal”来区分这两个。 - BlueRaja - Danny Pflughoeft
显示剩余4条评论

81

因为未取消关联事件而导致内存泄漏。

甚至有一些我认识的资深开发人员也会犯这个错误。

想象一下一个有很多东西的WPF表单,在其中某个位置上订阅了一个事件。如果您不取消订阅,那么整个表单在关闭和删除引用后仍会保留在内存中。

我看到的问题是在WPF表单中创建了一个DispatchTimer并订阅了Tick事件,如果您没有在计时器上执行-=操作,那么您的表单将泄漏内存!

在这个例子中,您的拆卸代码应该包含

timer.Tick -= TimerTickEventHandler;

这个问题特别棘手,因为你在WPF表单内创建了DispatchTimer实例,所以你会认为它是由垃圾收集过程处理的内部引用......不幸的是,DispatchTimer使用UI线程上订阅和服务请求的静态内部列表,因此引用是由静态类“拥有”的。


1
关键是始终释放您创建的所有事件订阅。如果您开始依赖于窗体自动完成此操作,那么您肯定会养成这个习惯,并且有一天会忘记在需要完成的某个地方释放事件。 - Jason Williams
3
这里有一个针对弱引用事件的MS-connect建议链接在此,这将解决这个问题,尽管我认为我们应该完全替换这个非常糟糕的事件模型,并采用像CAB使用的弱耦合模型。 - BlueRaja - Danny Pflughoeft
+1,谢谢!不过我对于我所做的代码审查工作并不感激。 - Bob Denny
@BlueRaja-DannyPflughoeft 在使用弱事件时,你需要注意另一个问题——你不能订阅 lambda 表达式。你不能写成 timer.Tick += (s, e,) => { Console.WriteLine(s); } - Ark-kun
@Ark-kun 是的,lambda使得这更加困难,你需要将lambda保存到一个变量中,并在拆卸代码中使用它。这有点破坏了编写lambda的简单性是吧? - Timothy Walters
只有当发布者位于引用树中较低的位置(即更靠近应用程序根目录)时,才会出现这个问题,比如静态类或父类。这相当于发布者持有对订阅者的引用。如果您删除所有对订阅者的其他引用,则发布者仍将保留引用,从而避免其被收集并促进内存问题。人们喜欢过度热衷于此。我的意思是它不会伤害任何东西,但在许多情况下,这是不必要的,并增加了复杂性。 - Jordan

63

也许不算是一个陷阱,因为这种行为在MSDN中已经被明确写出来了,但有一次却让我感到很费解:

Image image = System.Drawing.Image.FromFile("nice.pic");

这个人在图像被释放之前将"nice.pic"文件锁定。当我面对它时,我认为动态加载图标很好,一开始没有意识到自己最终会拥有数十个打开且被锁定的文件!Image 跟踪从哪里加载文件...

如何解决这个问题?我想用一个单行代码来完成任务。我期望 FromFile() 有一个额外的参数,但实际上没有,所以我写了这个...

using (Stream fs = new FileStream("nice.pic", FileMode.Open, FileAccess.Read))
{
    image = System.Drawing.Image.FromStream(fs);
}

11
我同意这种行为没有任何意义。除了“这是设计上的行为”之外,我找不到其他的解释。 - MusiGenesis
1
哦,这个解决办法的好处在于,如果您稍后尝试调用Image.ToStream(我记不清确切名称),它将无效。 - Joshua
57
需要检查一些代码。马上回来。 - Esben Skov Pedersen
7
这样一个简单却有趣幽默的评论,让我开心了一整天。 - Inisheer

51

重载 == 运算符和无类型容器(例如ArrayList、DataSet等):

string my = "my ";
Debug.Assert(my+"string" == "my string"); //true

var a = new ArrayList();
a.Add(my+"string");
a.Add("my string");

// uses ==(object) instead of ==(string)
Debug.Assert(a[1] == "my string"); // true, due to interning magic
Debug.Assert(a[0] == "my string"); // false

解决方案?

  • 在比较字符串类型时,始终使用 string.Equals(a, b)

  • 使用泛型,例如 List<string>,确保两个操作数都是字符串类型。


6
你的代码里有多余的空格,导致它完全错误,但是如果你去掉这些空格,最后一行仍然是正确的,因为 "my" + "string" 仍然是一个常量。 - Jon Skeet
1
哎呀!你说得对 :) 好的,我稍微编辑了一下。 - Jimmy
11
是的,C#语言中最大的缺陷之一就是在Object类中的==运算符。他们应该强制我们使用ReferenceEquals。 - erikkallen
@Earlz:C#中的运算符从不是虚拟的。 - Jimmy
2
感谢自2.0以来,我们拥有了泛型。如果您在上面的示例中使用List<string>而不是ArrayList,则要担心的事情就少了。此外,我们从中获得了性能提升,太好了!我总是在清除我们遗留代码中对ArrayList的旧引用。 - JoelC
显示剩余3条评论

51
如果考虑ASP.NET的话,我认为Webforms的生命周期对我来说是一个很大的坑。由于许多开发人员并不真正理解何时使用哪个事件处理程序(包括我自己),所以我花费了无数小时调试编写不良的Webforms代码。

26
这就是为什么我转向了MVC...因为视图状态很让人头疼... - chakrit
29
有一个完全专门讨论ASP.NET陷阱的问题(当然值得这样做)。ASP.NET的基本概念是为了让Web应用程序对开发人员来说看起来像Windows应用程序,这个想法非常错误,我甚至不确定它是否属于“陷阱”。 - MusiGenesis
1
MusiGenesis,我希望我能给你的评论点赞一百次。 - csauve
4
现在看来似乎有些错误,但当时人们想要他们的Web应用程序(应用程序是关键词 - ASP.NET WebForms并不是真正设计用于托管博客)与他们的Windows应用程序行为相同。这种情况直到最近才有所改变,而且仍然有很多人“还没有完全适应”。整个问题在于抽象程度过高 - 网络行为与桌面应用程序截然不同,这导致几乎所有人都感到困惑。 - Luaan
1
具有讽刺意味的是,我第一次看到有关ASP.NET的内容是来自微软的视频,演示了使用ASP.NET轻松创建博客网站的过程! - MusiGenesis

50
[Serializable]
class Hello
{
    readonly object accountsLock = new object();
}

//Do stuff to deserialize Hello with BinaryFormatter
//and now... accountsLock == null ;)
故事寓意:在反序列化对象时,字段初始值不会被运行。

8
是的,我讨厌.NET序列化因为它不会运行默认构造函数。我希望无法在调用任何构造函数的情况下构建一个对象,但可惜的是它不行。 - Roman Starkov

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接