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个回答

19

垃圾回收和Dispose()。虽然您不必采取任何措施释放内存,但仍需通过Dispose()释放资源。当您使用WinForms或以任何方式跟踪对象时,这是一个极易被忽略的问题。


2
using()块很好地解决了这个问题。每当您看到对Dispose的调用时,您可以立即安全地重构为使用using()。 - Jeremy Frey
5
我认为关注点在于正确实现 IDisposable。 - Mark Brackett
4
另一方面,使用“using()”习惯可能会出乎意料地造成问题,例如在使用PInvoke时。您不希望处理掉API仍在引用的内容。 - MusiGenesis
3
正确实现IDisposable即使是对于最好的建议,也很难理解和应用。即使使用.NET Framework指南,直到你最终“领悟”它,仍可能会感到困惑。 - Quibblesome
1
@JeremyFrey 不对,你可能在声明该值的初始块结束后很久才进行处理。 - Camilo Martin
显示剩余2条评论

18

Stream.Read的契约是我看到许多人被绊倒的东西:

// Read 8 bytes and turn them into a ulong
byte[] data = new byte[8];
stream.Read(data, 0, 8); // <-- WRONG!
ulong data = BitConverter.ToUInt64(data);
这样做的问题在于,Stream.Read 最多只会读取指定数量的字节,但它完全可以仅读取 1 个字节,即使在流结束之前还有其他 7 个字节可用。
这看起来与 Stream.Write 非常相似,后者如果没有异常返回,则保证已写入所有字节。上面的代码几乎总是有效,这也没什么帮助。当然,没有现成的、方便的方法可以正确地读取确切的 N 个字节,这也不帮助解决问题。
因此,为了填补漏洞并提高对此的认识,下面是一个示例,展示了一种正确的方法来处理这个问题:
    /// <summary>
    /// Attempts to fill the buffer with the specified number of bytes from the
    /// stream. If there are fewer bytes left in the stream than requested then
    /// all available bytes will be read into the buffer.
    /// </summary>
    /// <param name="stream">Stream to read from.</param>
    /// <param name="buffer">Buffer to write the bytes to.</param>
    /// <param name="offset">Offset at which to write the first byte read from
    ///                      the stream.</param>
    /// <param name="length">Number of bytes to read from the stream.</param>
    /// <returns>Number of bytes read from the stream into buffer. This may be
    ///          less than requested, but only if the stream ended before the
    ///          required number of bytes were read.</returns>
    public static int FillBuffer(this Stream stream,
                                 byte[] buffer, int offset, int length)
    {
        int totalRead = 0;
        while (length > 0)
        {
            var read = stream.Read(buffer, offset, length);
            if (read == 0)
                return totalRead;
            offset += read;
            length -= read;
            totalRead += read;
        }
        return totalRead;
    }

    /// <summary>
    /// Attempts to read the specified number of bytes from the stream. If
    /// there are fewer bytes left before the end of the stream, a shorter
    /// (possibly empty) array is returned.
    /// </summary>
    /// <param name="stream">Stream to read from.</param>
    /// <param name="length">Number of bytes to read from the stream.</param>
    public static byte[] Read(this Stream stream, int length)
    {
        byte[] buf = new byte[length];
        int read = stream.FillBuffer(buf, 0, length);
        if (read < length)
            Array.Resize(ref buf, read);
        return buf;
    }

1
或者,以您的显式示例为例:var r = new BinaryReader(stream); ulong data = r.ReadUInt64();。BinaryReader也有一个FillBuffer方法... - jimbobmcgee

18

foreach 循环变量的作用域!

var l = new List<Func<string>>();
var strings = new[] { "Lorem" , "ipsum", "dolor", "sit", "amet" };
foreach (var s in strings)
{
    l.Add(() => s);
}

foreach (var a in l)
    Console.WriteLine(a());

这段代码输出了五次"amet",而下面的示例可以正常工作

var l = new List<Func<string>>();
var strings = new[] { "Lorem" , "ipsum", "dolor", "sit", "amet" };
foreach (var s in strings)
{
    var t = s;
    l.Add(() => t);
}

foreach (var a in l)
    Console.WriteLine(a());

12
这基本等同于Jon用匿名方法的例子。 - Mehrdad Afshari
3
使用for循环时,索引变量在每次迭代中都是相同的,这一点更加清晰明了。但使用foreach循环时,“s”变量很容易与作用域变量混淆,使得情况更加混乱。 - Mikko Rantanen
2
http://blogs.msdn.com/ericlippert/archive/2009/11/12/closing-over-the-loop-variable-considered-harmful.aspx是的,希望变量被正确地作用域限定。 - Roman Starkov
3
这个问题在C# 5中得到了修复。 - Johnbot
你本质上只是一遍又一遍地打印同一个变量而没有改变它。 - Jordan
1
@Johnbot 不是固定的,而是改变的。 - IS4

18

一个让人沮丧的Linq缓存陷阱

查看我的问题,它导致了这个发现,以及发现问题的博主

简而言之,DataContext会缓存你曾经加载过的所有Linq-to-Sql对象。如果其他用户对你之前加载的记录进行任何更改,即使你明确地重新加载该记录,你也无法获取到最新数据,这是因为DataContext上默认启用了ObjectTrackingEnabled属性。如果将该属性设置为false,则每次都会重新加载记录...但是...你不能使用SubmitChanges()持久化对该记录所做的任何更改。

注意!


我刚刚花了一天半的时间(还有无数根头发!)追踪这个 BUG... - Surgical Coder
这被称为并发冲突,即使现在有某些方法可以解决它,但它仍然是一个陷阱,尽管这些方法往往有点笨重。DataContext 是一场噩梦。O_o - Jordan

18

MS SQL Server无法处理1753年之前的日期。这与.NET DateTime.MinDate常量不同,后者是1/1/1。因此,如果您尝试保存最早日期、格式错误的日期(最近在数据导入中发生了这种情况)或威廉征服者的出生日期,那么您将会遇到麻烦。没有内置的解决方案;如果您可能需要使用1753年之前的日期,则需要编写自己的解决方案。


17
说实话,我认为MS SQL Server是正确的,而.Net是错误的。 如果你进行研究,你就会知道由于日历变更、完全跳过的日期等原因,1751年之前的日期变得异常。 大多数关系型数据库管理系统都有某个截止点。这应该给你一个起点:http://www.ancestry.com/learn/library/article.aspx?article=3358 - NotMe
11
另外,日期是1753年。这基本上是我们第一次拥有一个连续的日历,没有跳过日期。SQL 2008引入了Date和datetime2数据类型,可以接受从01/01/01到12/31/9999的日期。然而,如果你真的要比较1753年之前的日期,使用这些类型进行日期比较应该持怀疑态度。 - NotMe
哦,对了,1753,已更正,谢谢。 - Shaul Behr
使用这样的日期进行日期比较真的有意义吗?我的意思是,对于历史频道来说,这非常有意义,但我不认为自己想知道美洲大陆被发现的确切星期几。 - Camilo Martin
5
通过维基百科上的"朱利安日"词条,你可以找到一份名为CALJD.BAS的13行基本程序,它可以进行日期计算并且能够追溯到公元前5000年左右,同时考虑了闰日和1753年跳过的日期。因此,我不明白为什么“现代”系统如SQL2008不能做得更好。你可能对15世纪的正确日期表示不感兴趣,但其他人可能会,我们的软件应该能够处理这一点而没有错误。另一个问题是闰秒... - Roland
由于这些问题,系统通常使用https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar,这意味着过去的日期就好像我们今天遵循相同的日历规则一样,即使这在历史上是不正确的。或者使用更好的日期时间框架,例如@jon-skeet Jon Skeet的NodaTime http://nodatime.org/。 - as9876

16

今天我修复了一个长期存在的错误。这个错误出现在一个通用类中,该类在多线程场景中使用,并且使用静态int字段来提供无锁同步,使用Interlocked。该错误的原因是每个类型的通用类的每个实例都有自己的静态字段。因此,每个线程都获得自己的静态字段,并且没有像预期那样使用锁。

class SomeGeneric<T>
{
    public static int i = 0;
}

class Test
{
    public static void main(string[] args)
    {
        SomeGeneric<int>.i = 5;
        SomeGeneric<string>.i = 10;
        Console.WriteLine(SomeGeneric<int>.i);
        Console.WriteLine(SomeGeneric<string>.i);
        Console.WriteLine(SomeGeneric<int>.i);
    }
}

这会打印出

5 10 5

6
你可以拥有一个非泛型的基类,定义静态成员,然后从中继承泛型。虽然我从未在C#中遇到这种情况,但我仍然记得一些C++模板的长时间调试小时...呃! :) - Paulius
7
奇怪,我认为这很明显。只需考虑如果“i”具有类型“T”,它应该执行什么操作。 - Timwi
1
类型参数是 Type 的一部分。SomeGeneric<int>SomeGeneric<string> 是不同的类型;因此,它们各自拥有自己的 public static int i - radarbob

16

事件

我从未理解为什么事件是一种语言特性。它们很难使用:在调用之前需要检查 null 值,需要取消注册(自己),无法找出谁注册了该事件(例如:我是否注册过?)。为什么事件不只是库中的一个类呢?基本上它就是一个专门的 List<delegate> 吗?


1
此外,多线程编程也是很痛苦的。除了空指针问题之外,所有这些问题都在CAB中得到了解决(其特性实际上应该内置于语言中)——事件被全局声明,任何方法都可以声明自己是任何事件的“订阅者”。我对CAB唯一的问题是全局事件名称是字符串而不是枚举(这可以通过更智能的枚举来解决,就像Java一样,它本质上可以作为字符串工作!)。CAB难以设置,但有一个简单的开源克隆版本可用这里 - BlueRaja - Danny Pflughoeft
4
我不喜欢 .net 事件的实现方式。订阅事件应该通过调用一个添加订阅并返回 IDisposable 的方法来处理,当 Dispose 完成后,将删除订阅。没有必要使用特殊结构来组合“添加”和“删除”方法,其语义可能有些棘手,特别是如果尝试添加然后删除多路广播委托(例如添加“B”,然后“AB”,然后删除“B”(仍保留“BA”)和“AB”(仍保留“BA”)。糟糕。 - supercat
@supercat 你会如何重写 button.Click += (s, e) => { Console.WriteLine(s); } - Ark-kun
如果我需要能够单独取消订阅其他事件,可以使用 IEventSubscription clickSubscription = button.SubscribeClick((s,e)=>{Console.WriteLine(s);}); 进行订阅并通过 clickSubscription.Dispose(); 进行取消订阅。如果我的对象需要在其整个生命周期内保留所有订阅,则可以使用 MySubscriptions.Add(button.SubscribeClick((s,e)=>{Console.WriteLine(s);})); 进行订阅,然后使用 MySubscriptions.Dispose() 来取消所有订阅。 - supercat
@Ark-kun:不得不保留封装了外部订阅的对象可能看起来很麻烦,但把订阅视为实体将使聚合它们成为可能,使用可以确保它们都被清理的类型,否则这将非常困难。 - supercat
@Ark-kun:顺便说一下,将订阅作为实体的另一个好处是,如果事件发布者保留与其订阅相关联的实体的链接列表,则仅需要使其订阅对象无效即可取消订阅对象--因为当事件触发时,事件发布者必须轮询附加的实体,它可以在那个时候安全地分离它们;如果事件没有触发,发布者可以在添加新事件时定期检查无效的订阅。 - supercat

14

刚刚发现一个奇怪的问题导致我花了很长时间进行调试:

你可以将可空整数的值加1而不会抛出异常,并且该值仍为null。

int? i = null;
i++; // I would have expected an exception but runs fine and stays as null

这就是C#如何利用可空类型操作的结果。它有点类似于NaN消耗你所抛出的一切。 - IS4

13

可枚举对象可以被多次评估

当你迭代一个惰性枚举的可枚举对象两次并获得不同的结果时,它会使你犯错。(或者你会获得相同的结果,但会不必要地执行两次)

例如,在编写某个测试时,我需要一些临时文件来测试逻辑:

var files = Enumerable.Range(0, 5)
    .Select(i => Path.GetTempFileName());

foreach (var file in files)
    File.WriteAllText(file, "HELLO WORLD!");

/* ... many lines of codes later ... */

foreach (var file in files)
    File.Delete(file);

当我使用File.Delete(file)时,出现了FileNotFound的异常,让我很吃惊!

发生的情况是files枚举被迭代了两次(第一次迭代的结果只是没有被记住),并在每个新的迭代中重新调用Path.GetTempFilename(),所以你会得到不同的临时文件名。

解决方法当然是通过使用ToArray()ToList()来急切地枚举这个值:

var files = Enumerable.Range(0, 5)
    .Select(i => Path.GetTempFileName())
    .ToArray();

当你进行一些多线程的操作时,情况会变得更加可怕,比如:

foreach (var file in files)
    content = content + File.ReadAllText(file);

然后你发现在所有的写入后content.Length仍为0!你开始严格检查,确保没有竞争条件...浪费了一个小时后...你发现只是那个微小的Enumerable问题被忽略了....


这是有意为之的。它被称为延迟执行。除其他外,它旨在模拟TSQL结构。每次从SQL视图中选择时,您会获得不同的结果。它还允许链接,这对于远程数据存储非常有帮助,例如SQL Server。否则,x.Select.Where.OrderBy将向数据库发送3个单独的命令... - as9876
@AYS,你在问题标题中漏掉了“Gotcha”这个词吗? - chakrit
我原以为 "gotcha" 指的是设计师的疏忽,而不是一些有意为之的事情。 - as9876
也许对于不可重新启动的IEnumerables应该有另一种类型,比如AutoBufferedEnumerable?这可以很容易地实现。这个问题似乎主要是由程序员缺乏知识造成的,我认为当前的行为没有任何问题。 - Eldritch Conundrum

10

这是一个非常棘手的问题,我浪费了2天时间来排查。它没有抛出任何异常,只是用一些奇怪的错误消息使Web服务器崩溃了。我无法在DEV中重现该问题。此外,对项目构建设置的实验不知何故让它在PROD中消失了,然后又回来了。最终我解决了问题。

如果你看到以下代码有问题,请告诉我:

private void DumpError(Exception exception, Stack<String> context)
{
    if (context.Any())
    {
        Trace.WriteLine(context.Pop());
        Trace.Indent();
        this.DumpError(exception, context);
        Trace.Unindent();
    }
    else
    {
        Trace.WriteLine(exception.Message);
    }
}

如果你重视自己的理智:

!!! 永远不要在 Trace 方法中放置任何逻辑 !!!

代码应该是这样的:

private void DumpError(Exception exception, Stack<String> context)
{
    if (context.Any())
    {
        var popped = context.Pop();
        Trace.WriteLine(popped);
        Trace.Indent();
        this.DumpError(exception, context);
        Trace.Unindent();
    }
    else
    {
        Trace.WriteLine(exception.Message);
    }
}

这会让我加1。我从未知道.NET会抛弃代码(就像C ++调试模式中的assert一样)。 - user34537

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