在上小学有一道题目是半杯50度的水加上半杯50度的水等于什么,我傻傻写了半杯100度的水。当时我还是逗者级别的,现在是逗尊级别了。在写代码的时候会看到莫名一个不带单位的变量或属性,总是会觉得我会加出100度出来。什么是不带单位的属性?例如我看到了有人写了一个属性叫字体大小的,这个属性是 double 值,这就好玩了,请问这是一个像素单位还是磅单位。程序猿修养给属性一个单位,可以提升代码可读性

还是回到开始的题目,在计算机里面也是无视物理规则,真的加上去也没问题。如果要让两个不相关的值不能相加,最简单的方式是让这两个类型不相同,至少不能直接相加。这句话请大杠不要说隐式转换的问题

在写代码的时候我推荐定义的属性或变量,如果存在一些单位,同时单位还存在不统一时,给这些属性加上单位。加上单位的做法就是定义对应的单位类或结构体等。这样可以在写代码的时候强制要求参数传入的单位以及计算时知道单位

为什么这样建议,请看看本文的例子

我在写文本库的时候就遇到一些有趣的问题

二代文本开发者在文本的字体大小里面混用了像素和磅单位,此时就好玩了。有一个 SetFontSize 方法,这个方法传入的参数是 double value 也就是传入一个 double 值,这个值的单位是磅单位。但是在 GetFontSize 方法里面,返回值也是 double 只是单位就修改为了像素

当然这个问题也许小伙伴觉得不是很坑,因为很简单就能从代码层面理解,下面再给大家讲另一个更坑的问题

还是在文本库里面,文本有分行和分段的概念,此时每个字符的坐标可以分为,这个字符在整个文档里面排第几个字,这个字符在段里面属于第几个字符,这个字符在行里面属于第几个字符。在文本库里面逗比的开发者用了 int 表示,这就好玩了,在调试的时候进入方法内,看到一个 charOffset 变量,请问这个变量代表的是这个字符在文档里面的偏移量还是代表在行内的?其实只有从最上面开始读才知道。如果此时的技术混杂了段内计算,也就有另一个 charOffset2 的存在,如果这个 charOffset2 是一个段内坐标,那么这个代码就更好玩了,就和 C++ 的指针一样

public bool AreSame(int charOffset)
{
	string text = GetText(charOffset);
	int charOffset2 = GetCurrentOffset();
	string text2 = GetText(charOffset2);

	// 忽略代码
}

上面的代码存在坑,但是其实很难通过测试手段或看代码了解,因为如上面说的 charOffset 是文档里面的偏移量,但是 charOffset2 是一个段内坐标,而 GetText 方法就不知道接收参数是什么坐标了。如果此时是在第一段里面,那么文档坐标和段坐标是重合的,测试小伙伴也基本上只会测试输入一行文本。而从代码调试上也只能看到传入的值是一个 int 拿到一个字符串,为什么明明相同的字符串返回的是不同就好玩了

想要写出让小伙伴看不懂的代码,有一条方法就是在一段代码里面使用多个不同的单位,但是多个不同单位的变量使用相同的类型。反过来,想要写出小伙伴或自己能看懂的代码,要么只使用一个单位,如果有多个单位那么给单位定义类型,不同的单位要求转换

再对比优化后的代码,其实优化后只是将 int 转换为我定义的不同的类型

public bool AreSame(DocumentOffset charOffset)
{
	string text = GetText(charOffset);
	ParagraphOffset charOffset2 = GetCurrentOffset();
	string text2 = GetText(charOffset2);

	// 忽略代码
}

此时的逻辑就更清晰了,当然坑也解决了,因为 GetText 是一个重载方法,在拿到参数也就知道返回的是什么坐标的字符

让代码调试简单的做法,减少写出坑的做法就是尽可能给单位一个类型,不要使用不带单位的基础类型

如我在写 Word 文档的时候,在 Word 文档里面有很多有趣的单位,如磅单位和像素单位都是表示文本大小的单位,此时我就定义了两个不同的结构体

分别是结构体磅单位和结构体像素单位

    /// <summary>
    /// 磅
    /// </summary>
    public readonly struct Pound
    {
        /// <summary>
        /// 磅
        /// </summary>
        /// <param name="value"></param>
        public Pound(double value)
        {
            Value = value;
        }

        /// <summary>
        /// 磅
        /// </summary>
        public double Value { get; }
    }
    /// <summary>
    /// 像素
    /// </summary>
    public readonly struct Pixel
    {
        public Pixel(double value)
        {
            Value = value;
        }

        public double Value { get; }
    }

上面代码的 readonly struct 是 C# 7.0 的语法,不是本文的重点,此时定义为类也可以

这样在调试代码或看代码看到一个变量,就可以通过变量类型知道单位,而不是需要根据代码上下文才能了解到单位

即使在原有内部比较混乱的混合磅和像素的算计,都能通过类型辅助静态代码定位。这里静态代码定位说的是在不执行调试下,靠看代码定位问题

小伙伴也能注意到我没有给隐式转换两个不同的类型的代码,这是因为这两个类型就是让他完全不同,提高混用的难度

除了在设计类的时候可以用此方法,在使用框架或别人设计的类的时候也推荐使用带单位的方法

我比较推荐在代码中显式定义单位,或传入单位,如小伙伴会用到的 Task 延迟的代码。如下面两句代码

   await Task.Delay(100);

   await Task.Delay(TimeSpan.FromMilliseconds(100));

小伙伴觉得上面两句代码哪句比较好,从性能的角度上说,使用 TimeSpan.FromMilliseconds 参数的 Task 内部也会转换 int 传入到第一句里面。也就是第一句的性能很比第二句好,代码字符数上第一句也比较短

但是从可读上说,抛开 Task.Delay 是熟知的 API 来说,传入一个 100 不带单位其实有点伤,因为不确定这个 100 的含义是什么,等待 100 次还是等待 id 为 100 这个线程?而第二句话就能告诉开发者这是一个延迟 100 毫秒的代码

当然对于一些熟知的 API 来说,这样写的意义不大。但是这句话也是不对的,因为对我来说 Foo 方法是熟知的,但是对小伙伴来说根本不了解这个 Foo 方法是什么。保险做法是按照可读性强的写

在调用现有框架 API 的时候依然推荐使用带单位的方法,这样阅读代码的时候,不熟悉这个 API 都能方便从参数里面了解对应的单位

还有一个有争议的例子

小伙伴觉得设计一个方法,这个方法接收输入的内容是一个文件,此时用 string 表示文件路径好还是用 FileInfo 好?

我提两个问题,从框架或库设计的方面

第一个问题是在使用文件相关的库的时候,遇到最多的文件就是相对路径问题。经常有小伙伴传入的是相对路径,此时换算为绝对路径就出错了。但是很难约束和告诉小伙伴,如果传入相对路径将会相对哪里

第二个问题是文件夹和文件的分开比较难设计,只要夹杂文件夹和文件然后参数都是 string 时,经常会收到小伙伴反馈用错,即使写明了方法是 Folder 也一样。另外如果方法内部混杂了使用 string 代表文件路径和 string 代表文件内容等,有时会混用值,即使这部分很好调试

但是用 string 作为文件代表确实是比较自然的方式,代码写起来也比较简单。只是约束斜杠相对路径什么的坑比较多。另外在 .NET Core 开源框架部分也比较多采用 string 作为文件路径

如 File 等静态类,就提供了从 string 作为文件名的读取方法,这个方法的使用量特别多,也很少见出现坑

从这个方面说,更多在于约定之上,没有说哪个方法会更好

大概,本文要安利大家的内容就这些

本文的内容很简单,就是一句话,给属性一个单位

给属性一个单位的优点是

  • 明确单位,减少混用
  • 减少单位带来的坑
  • 提升代码可读性
  • 约束调用方参数
  • 减少库代码参数判断代码

缺点是

  • 需要进行转换,转换代码影响性能
  • 增加代码的字符数

最简单判断要不要加单位的方法就是尝试加一下单位,如果代码写起来还好,那么就加上单位


本文会经常更新,请阅读原文: https://dotnet-campus.github.io//post/%E7%A8%8B%E5%BA%8F%E7%8C%BF%E4%BF%AE%E5%85%BB-%E7%BB%99%E5%B1%9E%E6%80%A7%E4%B8%80%E4%B8%AA%E5%8D%95%E4%BD%8D.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名 lindexi (包含链接: https://dotnet-campus.github.io/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系