慎用隐式类型转换

Posted by eagleboost on April 5, 2024

  .Net允许用户自定义运算符operator来实现两个类型之间的转换以提高代码灵活性。自定义运算符分为显式和隐式两种。显式运算符需要在代码中明确地把目标类型写出来,隐式运算符则由编译器自动检测和推断类型再生成相应代码。

  比如有下面这样一个类型,可用于根据string类型的id查找一个由泛型参数T指定的对象。

1
2
3
4
5
6
public sealed class LookupItem<T>(string id)
{
  public readonly string Id = id;
  
  public T? Data { get; set; }
}

  业务逻辑中可能有很多地方需要访问LookupItem<T>.IdLookupItem<T>.Data,我们就可以用自定义运算符来简化代码。

1
2
3
4
5
//隐式
public static implicit operator string(LookupItem<T> item) => item.Id;

//显式
public static explicit operator T?(LookupItem<T> item) => item.Data;

  这样一来下面的代码就是合法的,编译器能检测到隐式和显式类型转换运算符的存在并生成代码来调用相应的运算符。

1
2
3
4
5
6
7
public void SomeMethod()
{
  var lookupItem = new LookupItem<int>("ABC") { Data = 1 };

  string id = lookupItem; ////隐式转换到string, id="ABC"
  var data = (int)lookupItem; ////显式转换到int, data=1
}

下面的代码则不合法:

1
var boolean = (bool)lookupItem; ////编译错误

  显式转换用起来没有问题,毕竟目标类型需要明确在代码中给出,但隐式转换则需要谨慎使用,小心踩坑,否则可能导致不易排查的bug

  举个例子。假设我们需要把一些不同类型的数据按<Key, Value>放到一个字典中(把不同类型的数据放入同一个字典的做法值得商榷,但这里并不妨碍用它来说明问题),在放之前检查数据是否为空,不为空则放入字典。容易写出如下几个重载方法SetIfHasValue分别对objectstringNullable<T>值类型作不同的检查,分别用V1V2V3表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//V1
private void SetIfHasValue(string key, object? value)
{
  if (value != null)
  {
    SetValueCore(key, value);
  }
}

//V2
private void SetIfHasValue(string key, string? value)
{
  if (!string.IsNullOrEmpty(value))
  {
    SetValueCore(key, value);
  }
}

//V3
private void SetIfHasValue<T>(string key, T? value) where T : struct
{
  if (value.HasValue)
  {
    SetValueCore(key, value.Value);
  }
}

private void SetValueCore<T>(string key, T value)
{
  ////保存到字典
}

  那么问题来了。对于下面的代码,在没有实现隐式类型转换运算符之前,编译器会推断为调用V1版本的SetIfHasValue,这也符合我们的要求——把LookupItem<T>保存到字典中。但在实现了隐式类型转换运算符后,聪明的编译器发现LookupItem<T>可以隐式转换为string,于是推断为V2版本的SetIfHasValue(参数类型为string)更适合,于是代码执行下来默默地把字符串ABC放到了字典中,bug产生了。

1
2
var lookupItem = new LookupItem<int>("ABC") { Data = 1 };
SetIfHasValue(id, lookupItem);

  尽管.Net/C#的各种功能和改进大都经过精心设计以及来自众多开发者的反馈,但并不是所有功能的适用范围都类似。实践下来通过自定义运算符实现隐式类型转换是少数应该避免使用的功能之一(自定义运算符本身已经极为小众了)。