前言

在 C# 开发中,很多问题并不是语法不会,而是一些“看起来没问题”的代码,在真实项目里非常容易出 Bug。

尤其是在 WinForms、ASP.NET、数据库操作、多线程、集合遍历、字符串处理等场景中,一些细节如果处理不好,轻则程序异常,重则数据错乱、界面卡死、数据库被注入攻击。

本文总结几个 C# 开发中非常常见的坑,并附上错误写法、正确写法和原因分析。


一、NullReferenceException:空引用异常

这是 C# 中最经典、最常见的异常之一。

错误写法

User user = GetUser();

Console.WriteLine(user.Name);

如果 GetUser() 返回的是 null,那么访问 user.Name 时就会报错:

System.NullReferenceException: 未将对象引用设置到对象的实例

正确写法

User user = GetUser();

if (user != null)
{
    Console.WriteLine(user.Name);
}
else
{
    Console.WriteLine("用户不存在");
}

或者使用空条件运算符:

Console.WriteLine(user?.Name);

原因分析

C# 中的引用类型变量存储的是对象地址。如果变量为 null,说明它没有指向任何对象。

常见容易为空的对象有:

DataTable dt;
DataRow row;
List<T> list;
string str;
对象属性;
方法返回值;

所以在访问对象成员之前,要先判断对象是否为 null


二、字符串比较不要直接使用 == 忽略业务场景

错误写法

string input = "Admin";

if (input == "admin")
{
    Console.WriteLine("登录成功");
}

这段代码不会进入判断,因为 C# 默认区分大小写。

正确写法

string input = "Admin";

if (string.Equals(input, "admin", StringComparison.OrdinalIgnoreCase))
{
    Console.WriteLine("登录成功");
}

原因分析

== 可以比较字符串内容,但它默认是区分大小写的。

在账号、状态码、配置项判断中,如果业务允许忽略大小写,建议使用:

StringComparison.OrdinalIgnoreCase

它比转换大小写更规范。

不推荐这样写:

if (input.ToLower() == "admin")
{
}

因为如果 inputnull,会直接报空引用异常。


三、List 遍历时删除元素会报错

错误写法

List<int> nums = new List<int> { 1, 2, 3, 4, 5 };

foreach (int num in nums)
{
    if (num % 2 == 0)
    {
        nums.Remove(num);
    }
}

这段代码会报错:

Collection was modified; enumeration operation may not execute.

正确写法一:倒序 for 循环

List<int> nums = new List<int> { 1, 2, 3, 4, 5 };

for (int i = nums.Count - 1; i >= 0; i--)
{
    if (nums[i] % 2 == 0)
    {
        nums.RemoveAt(i);
    }
}

正确写法二:RemoveAll

nums.RemoveAll(x => x % 2 == 0);

原因分析

foreach 遍历集合时,集合结构不能被修改。删除元素会改变集合版本号,导致枚举器失效。

如果要删除元素,推荐:

RemoveAll()

或者使用倒序 for 循环。


四、decimal 和 double 不要混用,金额必须用 decimal

错误写法

double price = 0.1;
double total = price * 3;

Console.WriteLine(total);

你可能以为结果是:

0.3

但实际可能是:

0.30000000000000004

正确写法

decimal price = 0.1m;
decimal total = price * 3;

Console.WriteLine(total);

原因分析

double 是二进制浮点数,适合科学计算、图形计算,但不适合金额。

金额、余额、单价、总价、税率等业务数据,应该使用:

decimal

注意 decimal 字面量后面要加 m

decimal money = 99.99m;

五、DateTime.Now 滥用导致时间逻辑混乱

错误写法

DateTime startTime = DateTime.Now;

// 业务处理
Thread.Sleep(1000);

DateTime endTime = DateTime.Now;

Console.WriteLine((endTime - startTime).TotalSeconds);

这段代码在普通情况下没问题,但如果涉及服务器时间、跨时区、日志、接口对接,就可能出现问题。

推荐写法

DateTime startTime = DateTime.UtcNow;

// 业务处理
Thread.Sleep(1000);

DateTime endTime = DateTime.UtcNow;

Console.WriteLine((endTime - startTime).TotalSeconds);

原因分析

DateTime.Now 获取的是本地时间,会受系统时区影响。

如果是:

日志时间;
接口时间;
定时任务;
跨服务器时间比较;
数据库时间记录;

更推荐使用:

DateTime.UtcNow

如果只是 WinForms 本地显示当前时间,使用 DateTime.Now 没问题。


六、WinForms 中子线程不能直接操作 UI 控件

错误写法

Task.Run(() =>
{
    label1.Text = "处理完成";
});

这段代码可能报错:

线程间操作无效: 从不是创建控件的线程访问它

正确写法

Task.Run(() =>
{
    string result = "处理完成";

    this.Invoke(new Action(() =>
    {
        label1.Text = result;
    }));
});

更严谨写法

private void SetLabelText(string text)
{
    if (label1.InvokeRequired)
    {
        label1.Invoke(new Action(() =>
        {
            label1.Text = text;
        }));
    }
    else
    {
        label1.Text = text;
    }
}

调用:

Task.Run(() =>
{
    SetLabelText("处理完成");
});

原因分析

WinForms 控件只能在创建它的 UI 线程中访问。

后台线程负责耗时操作,UI 更新必须切回主线程。

常见场景:

Task.Run;
Thread;
BackgroundWorker;
Socket 接收数据;
串口回调;
定时任务回调;

这些地方都不能直接操作界面控件。


七、SQL 拼接字符串容易造成 SQL 注入

错误写法

string userName = txtUserName.Text;
string password = txtPassword.Text;

string sql = "select * from Users where UserName = '" 
             + userName + "' and Password = '" 
             + password + "'";

如果用户输入:

' or '1'='1

就可能绕过登录验证。

正确写法

string sql = "select * from Users where UserName = @UserName and Password = @Password";

using (SqlConnection conn = new SqlConnection(connStr))
using (SqlCommand cmd = new SqlCommand(sql, conn))
{
    cmd.Parameters.AddWithValue("@UserName", txtUserName.Text);
    cmd.Parameters.AddWithValue("@Password", txtPassword.Text);

    conn.Open();

    using (SqlDataReader reader = cmd.ExecuteReader())
    {
        if (reader.Read())
        {
            Console.WriteLine("登录成功");
        }
        else
        {
            Console.WriteLine("账号或密码错误");
        }
    }
}

原因分析

SQL 拼接有两个问题:

第一,容易 SQL 注入。

第二,字符串中如果包含单引号,SQL 语句会报错。

例如:

O'Connor

如果直接拼接进 SQL,就会破坏 SQL 语法。

所以数据库操作必须优先使用参数化查询。


八、SqlConnection、SqlDataReader 不释放会导致连接池耗尽

错误写法

SqlConnection conn = new SqlConnection(connStr);
SqlCommand cmd = new SqlCommand("select * from Users", conn);

conn.Open();

SqlDataReader reader = cmd.ExecuteReader();

while (reader.Read())
{
    Console.WriteLine(reader["UserName"]);
}

conn.Close();

如果中途异常,conn.Close() 可能不会执行,导致数据库连接没有及时释放。

正确写法

using (SqlConnection conn = new SqlConnection(connStr))
using (SqlCommand cmd = new SqlCommand("select * from Users", conn))
{
    conn.Open();

    using (SqlDataReader reader = cmd.ExecuteReader())
    {
        while (reader.Read())
        {
            Console.WriteLine(reader["UserName"]);
        }
    }
}

原因分析

SqlConnectionSqlCommandSqlDataReader 都实现了 IDisposable 接口。

使用 using 可以确保对象在使用完后自动释放,即使中途发生异常,也能执行释放逻辑。

数据库连接是稀缺资源,不能长时间占用。


九、DataTable 取值时没有判断 DBNull

错误写法

DataRow row = dt.Rows[0];

string name = row["Name"].ToString();
int age = Convert.ToInt32(row["Age"]);

如果数据库里的 AgeNULL,这段代码可能报错。

正确写法

DataRow row = dt.Rows[0];

string name = row["Name"] == DBNull.Value ? "" : row["Name"].ToString();

int age = row["Age"] == DBNull.Value ? 0 : Convert.ToInt32(row["Age"]);

或者封装一个通用方法:

public static string GetString(DataRow row, string columnName)
{
    if (row == null || row[columnName] == DBNull.Value)
    {
        return string.Empty;
    }

    return row[columnName].ToString();
}

public static int GetInt(DataRow row, string columnName)
{
    if (row == null || row[columnName] == DBNull.Value)
    {
        return 0;
    }

    return Convert.ToInt32(row[columnName]);
}

原因分析

数据库中的 NULL 到 C# 中不是 null,而是:

DBNull.Value

所以不能只判断:

row["Name"] == null

这通常是无效的。


十、int.Parse 容易报错,用户输入建议用 TryParse

错误写法

int age = int.Parse(txtAge.Text);

如果用户输入空字符串、中文、特殊符号,就会报异常。

正确写法

int age;

if (int.TryParse(txtAge.Text, out age))
{
    Console.WriteLine("年龄:" + age);
}
else
{
    MessageBox.Show("请输入正确的年龄");
}

原因分析

Parse 适合确定格式一定正确的场景。

用户输入、接口返回、配置文件读取,建议使用:

TryParse

常见类型都有类似方法:

int.TryParse();
decimal.TryParse();
DateTime.TryParse();
double.TryParse();

十一、异常不要直接吞掉

错误写法

try
{
    SaveData();
}
catch
{
}

这种写法非常危险。

程序明明出错了,但你完全不知道错误原因。

正确写法

try
{
    SaveData();
}
catch (Exception ex)
{
    LogError(ex);
    MessageBox.Show("保存失败,请联系管理员");
}

日志方法示例:

public static void LogError(Exception ex)
{
    string log = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")
                 + Environment.NewLine
                 + ex.ToString()
                 + Environment.NewLine
                 + "-----------------------------------"
                 + Environment.NewLine;

    File.AppendAllText("error.log", log);
}

原因分析

异常处理的核心不是“让程序不报错”,而是:

记录错误
保护程序
提示用户
方便排查

直接空 catch 会让问题隐藏起来,后期排查非常困难。


十二、不要在循环中频繁拼接字符串

错误写法

string result = "";

for (int i = 0; i < 10000; i++)
{
    result += i.ToString() + ",";
}

正确写法

StringBuilder sb = new StringBuilder();

for (int i = 0; i < 10000; i++)
{
    sb.Append(i);
    sb.Append(",");
}

string result = sb.ToString();

原因分析

string 是不可变对象。

每次执行:

result += "内容";

都会创建新的字符串对象。

如果循环次数很多,会产生大量临时对象,影响性能。

大量字符串拼接推荐使用:

StringBuilder

十三、List.Contains 判断对象时可能不符合预期

错误写法

List<User> users = new List<User>();

users.Add(new User { Id = 1, Name = "张三" });

bool exists = users.Contains(new User { Id = 1, Name = "张三" });

Console.WriteLine(exists);

你可能以为结果是:

true

但实际是:

false

原因分析

对于自定义对象,Contains 默认比较的是对象引用地址,而不是属性内容。

两个对象即使属性值一样,只要不是同一个实例,默认就是不相等。

正确写法一:使用 Any

bool exists = users.Any(x => x.Id == 1);

正确写法二:重写 Equals 和 GetHashCode

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }

    public override bool Equals(object obj)
    {
        User other = obj as User;

        if (other == null)
        {
            return false;
        }

        return this.Id == other.Id;
    }

    public override int GetHashCode()
    {
        return this.Id.GetHashCode();
    }
}

十四、异步方法 async void 慎用

错误写法

public async void LoadData()
{
    await Task.Delay(1000);
    throw new Exception("加载失败");
}

async void 中的异常很难被外部捕获。

正确写法

public async Task LoadDataAsync()
{
    await Task.Delay(1000);
}

调用:

try
{
    await LoadDataAsync();
}
catch (Exception ex)
{
    MessageBox.Show(ex.Message);
}

原因分析

除了事件处理方法之外,不建议使用 async void

例如 WinForms 按钮事件可以这样写:

private async void btnLoad_Click(object sender, EventArgs e)
{
    try
    {
        await LoadDataAsync();
    }
    catch (Exception ex)
    {
        MessageBox.Show(ex.Message);
    }
}

普通异步方法应该返回:

Task
Task<T>

这样调用方才能等待它完成,也能捕获异常。


十五、WinForms 界面卡死:耗时任务放在 UI 线程

错误写法

private void btnStart_Click(object sender, EventArgs e)
{
    Thread.Sleep(5000);

    MessageBox.Show("执行完成");
}

点击按钮后,界面会卡住 5 秒,用户无法拖动窗口,也无法点击其他按钮。

正确写法

private async void btnStart_Click(object sender, EventArgs e)
{
    btnStart.Enabled = false;

    await Task.Run(() =>
    {
        Thread.Sleep(5000);
    });

    btnStart.Enabled = true;

    MessageBox.Show("执行完成");
}

原因分析

WinForms 是单 UI 线程模型。

如果在 UI 线程执行耗时任务,消息循环会被阻塞,界面就会假死。

耗时操作应该放到后台线程,例如:

Task.Run();
BackgroundWorker;
Thread;

但是更新 UI 时,仍然要回到主线程。


十六、配置文件读取没有判断 Key 是否存在

错误写法

string value = ConfigurationManager.AppSettings["ApiUrl"].ToString();

如果配置文件中没有 ApiUrl,这里会报空引用异常。

正确写法

string value = ConfigurationManager.AppSettings["ApiUrl"];

if (string.IsNullOrEmpty(value))
{
    throw new Exception("配置项 ApiUrl 不存在");
}

原因分析

配置文件属于外部输入,不应该默认它一定存在。

尤其是部署到客户现场时,经常会出现:

配置文件缺失
配置项拼写错误
配置值为空
测试环境和正式环境配置不一致

所以读取配置时要做校验。


十七、密码不要明文存储

错误写法

string sql = "insert into Users(UserName, Password) values(@UserName, @Password)";
cmd.Parameters.AddWithValue("@Password", txtPassword.Text);

如果数据库泄露,所有用户密码都会直接暴露。

正确思路

密码不能明文存储,应该存储哈希值。

简单示例:

public static string Sha256(string input)
{
    using (SHA256 sha256 = SHA256.Create())
    {
        byte[] bytes = Encoding.UTF8.GetBytes(input);
        byte[] hash = sha256.ComputeHash(bytes);

        StringBuilder sb = new StringBuilder();

        foreach (byte b in hash)
        {
            sb.Append(b.ToString("x2"));
        }

        return sb.ToString();
    }
}

保存密码:

string passwordHash = Sha256(txtPassword.Text);

原因分析

密码安全的基本原则是:

不存明文密码
不直接可逆加密密码
使用哈希算法
最好加盐

实际项目中更推荐使用成熟方案,比如带盐哈希、PBKDF2、BCrypt 等。


十八、AddWithValue 可能导致 SQL Server 类型推断问题

常见写法

cmd.Parameters.AddWithValue("@UserName", userName);

这虽然方便,但在 SQL Server 中可能导致参数类型和数据库字段类型不一致,从而影响索引使用。

更推荐写法

cmd.Parameters.Add("@UserName", SqlDbType.NVarChar, 50).Value = userName;
cmd.Parameters.Add("@Age", SqlDbType.Int).Value = age;
cmd.Parameters.Add("@CreateTime", SqlDbType.DateTime).Value = DateTime.Now;

原因分析

AddWithValue 会根据传入的 C# 值自动推断 SQL 类型。

例如数据库字段是:

varchar(50)

但 C# 字符串可能被推断为:

nvarchar

在某些场景下会影响索引命中,导致查询性能下降。

小项目里可以用 AddWithValue,但对性能要求高的系统,推荐明确指定参数类型和长度。


十九、finally 中不要覆盖原异常

错误写法

try
{
    DoWork();
}
catch (Exception ex)
{
    throw ex;
}

正确写法

try
{
    DoWork();
}
catch
{
    throw;
}

原因分析

throw ex; 会重置异常堆栈信息,导致你看不到真正出错的位置。

throw; 会保留原始异常堆栈,更利于排查问题。


二十、判断字符串为空不要只用 == ""

错误写法

if (name == "")
{
    Console.WriteLine("姓名为空");
}

如果 namenull,这个判断不够完整。

正确写法

if (string.IsNullOrEmpty(name))
{
    Console.WriteLine("姓名为空");
}

如果还要判断空格:

if (string.IsNullOrWhiteSpace(name))
{
    Console.WriteLine("姓名为空");
}

原因分析

三种情况都可能代表用户没有有效输入:

null
""
"   "

推荐根据业务场景选择:

string.IsNullOrEmpty()
string.IsNullOrWhiteSpace()

总结

C# 开发中很多坑并不是语法层面的,而是工程实践层面的。

本文总结的常见问题包括:

1. 空引用异常
2. 字符串比较
3. 集合遍历删除
4. decimal 和 double 的区别
5. DateTime.Now 滥用
6. WinForms 跨线程操作 UI
7. SQL 字符串拼接
8. 数据库连接未释放
9. DBNull 判断
10. Parse 和 TryParse
11. 异常被吞掉
12. 字符串循环拼接
13. 对象 Contains 判断
14. async void 慎用
15. UI 线程卡死
16. 配置文件读取
17. 密码明文存储
18. AddWithValue 类型推断
19. throw ex 丢失堆栈
20. 字符串空值判断

写 C# 代码时,不仅要考虑“能不能运行”,还要考虑:

是否稳定
是否安全
是否易维护
是否方便排查问题
是否适合真实业务场景

真正成熟的开发,不是写出复杂代码,而是把简单代码写得稳定、清晰、可维护。

更多推荐