我们应该为所有事物定义类型吗?


141

最近,我遇到了代码可读性的问题。
我有一个执行操作的函数,并返回表示该操作ID的字符串以供将来参考(有点像Windows中的OpenFile返回句柄)。用户稍后将使用此ID来开始操作并监视其完成。

由于互操作性的考虑,该ID必须是随机字符串。这创建了一个签名非常不清楚的方法,如下所示:

public string CreateNewThing()

这使得返回类型的意图不清楚。我想将此字符串包装为另一种类型,从而使其含义更清晰,如下所示:

public OperationIdentifier CreateNewThing()

该类型将仅包含字符串,并且在使用此字符串时将使用该类型。

显然,这种操作方式的优点是类型安全性更高,意图更明确,但是它还会创建更多的代码,并且这些代码不是非常习惯。一方面,我喜欢增加的安全性,但同时也会造成很多混乱。

您是否出于安全原因将简单类型包装在类中是一种好习惯吗?


4
您可能对阅读Tiny Types感兴趣。我玩了一段时间,不推荐它,但这是一种有趣的思考方法。darrenhobbs.com/2007/04/11/tiny-types
Jeroen Vannevel

40
一旦使用了Haskell之类的东西,您将看到使用类型有多大帮助。类型有助于确保程序正确。
Nate Symer

3
即使是像Erlang这样的动态语言,也可以从类型系统中受益,这就是为什么它具有Haskellers熟悉的类型规范系统和类型检查工具的原因,尽管它是动态语言。诸如C / C ++之类的语言,其中任何东西的真实类型都是我们同意编译器以某种方式对待的整数,或者Java,如果您决定假装类为类型,则几乎可以归类为类型,这会在语言中带来语义重载问题语言(这是“类”还是“类型”?)或类型不明确(这是“ URL”,“ String”还是“ UPC”?)。最后,使用任何可以消除歧义的工具。
zxq9

7
我不确定C ++的想法从何而来。使用C ++ 11,没有一个编译器制造商看到为每个lambda赋予自己唯一类型的问题。但是TBH真的不能期望在有关“ C / C ++类型系统”的评论中找到很多见识,就像两者共享一个类型系统一样。
MSalters

2
@JDB-不,不是。没有相似之处。
达沃·德拉罗(DavorŽdralo)

Answers:


152

诸如string或的基元int在业务领域中没有任何意义。这样做的直接后果是,当期望产品ID时您可能会错误地使用URL,而在期望价格时,可能会使用数量

这也是为什么Object徒手健身挑战将原语包装作为其规则之一的原因:

规则3:包装所有原语和字符串

在Java语言中,int是原始的,而不是实际的对象,因此它遵循与对象不同的规则。它与非面向对象的语法一起使用。更重要的是,一个int本身仅仅是一个标量,因此没有任何意义。当方法将int作为参数时,方法名称需要完成所有表达意图的工作。如果相同的方法将“小时”作为参数,则更容易了解发生了什么。

同一文档还说明了另一个好处:

诸如Hour或Money之类的小物件也为我们提供了放置行为明显场所,否则这些行为本来会在其他类的周围乱扔

确实,使用原语时,通常很难跟踪与这些类型相关的代码的确切位置,这通常会导致严重的代码重复。如果有Price: Money类,很自然地可以在内部找到范围检查。相反,如果使用int(更糟糕的是double)来存储产品价格,那么谁应该验证范围?产品?回扣?购物车?

最后,文档中未提及的第三个好处是能够相对轻松地更改基础类型。如果今天我ProductIdshort作为其基本型,后来我需要使用int,而不是,有机会改变不会跨越整个代码库中的代码。

缺点-相同的论点适用于对象健美操锻炼的每条规则-就是如果很快变得不堪重负而无法为所有事物创建一个类。如果Product包含ProductPricePositivePrice哪个继承而从哪个继承又从Price继承Money,那么这不是干净的体系结构,而是一个完整的混乱局面,为了找到单个事物,维护者应该每次打开几十个文件。

要考虑的另一点是创建其他类的成本(就代码行而言)。如果包装器是不可变的(通常应该如此),则意味着,如果采用C#,则必须至少在包装器内:

  • 财产获取者,
  • 它的支持领域
  • 将值分配给后备字段的构造函数,
  • 自定义ToString()
  • XML文档注释(行很多),
  • A EqualsGetHashCode覆盖(也很多LOC)。

最终,在相关时:

  • 一个DebuggerDisplay属性,
  • ==!=运算符的替代,
  • 最终,隐式转换运算符的重载可以无缝地与封装类型进行转换,
  • 代码协定(包括不变式,这是一个相当长的方法,具有三个属性),
  • 在XML序列化,JSON序列化或将值存储到数据库或从数据库加载值时将使用几个转换器。

一个简单包装的100 LOC使其非常昂贵,这也是为什么您可以完全确定这种包装的长期盈利能力的原因。Thomas Junk解释的范围概念在这里特别重要。编写一百个LOC来代表ProductId整个代码库中的一个用例,看起来非常有用。为一段代码编写这种大小的类,使一个方法中的三行代码变得更加可疑。

结论:

  • 当(1)帮助减少错误,(2)减少代码重复的风险或(3)以后帮助更改基础类型时,请将原语包装在应用程序的业务域中有意义的类中。

  • 不要自动包装在代码中找到的每个原语:在许多情况下,使用stringint完全可以。

实际上,在中public string CreateNewThing(),返回ThingId类的实例代替string可能会有所帮助,但是您也可以:

  • 返回Id<string>类的实例,该实例是通用类型的对象,指示基础类型是字符串。您将获得可读性的好处,而不必维护许多类型。

  • 返回Thing类的实例。如果用户仅需要ID,则可以使用以下方法轻松完成此操作:

    var thing = this.CreateNewThing();
    var id = thing.Id;
    

33
我认为在这里自由更改底层类型的能力很重要。今天是字符串,但也许是GUID或明天很长。创建类型包装器会将信息隐藏起来,从而减少沿途的更改。++好的答案。
RubberDuck

4
对于货币,请确保货币要么包含在Money对象中,要么确保您的整个程序使用相同的货币(然后应将其记录在案)。
圣保罗Ebermann

5
@Blrfl:尽管在某些情况下有必要进行深度继承,但我通常会在强迫性按本地支付的态度下,在过度设计的代码中看到这种继承,而没有注意可读性。因此,这是我回答这一部分的负面特征。
阿森尼·穆尔琴科(Arseni Mourzenko)2015年

3
@ArTs:那是“让我们谴责这个工具,因为有些人以错误的方式使用它”的说法。
Blrfl 2015年

6
@MainMa有关含义可能避免了代价高昂的错误的示例:en.wikipedia.org/wiki/Mars_Climate_Orbiter#Cause_of_failure
cbojar 2015年

60

我将使用范围作为经验法则:生成和使用这样的范围的范围越窄values,创建表示该值的对象的可能性就越小。

假设您有以下伪代码

id = generateProcessId();
doFancyOtherstuff();
job.do(id);

那么范围是非常有限的,我认为将其设为类型是没有意义的。但要说的是,您可以在一个层中生成该值,然后将其传递给另一层(甚至另一个对象),然后为该值创建一个类型是很有意义的。


14
很好的一点。显式类型是传达意图的重要工具,跨对象和服务边界尤为重要。
Avner Shahar-Kashtan

+1,尽管如果您尚未将的所有代码重构doFancyOtherstuff()为子例程,您可能会认为job.do(id)引用的局部性不足以保留为简单字符串。
马克·赫德

这是完全正确的!在您拥有私有方法的情况下,您会知道外界永远不会使用该方法。之后,当该类受到保护时,以及当该类为公共但不属于公共api的部分时,您可以多想一点。范围范围范围和许多技巧可以使许多原始类型和许多自定义类型之间的界限更好。
塞缪尔

34

静态类型系统都是关于防止错误使用数据的。

有明显的例子可以做到这一点:

  • 你无法获得UUID的月份
  • 您不能将两个字符串相乘。

还有更多微妙的例子

  • 你不能用桌子的长度付钱
  • 您不能使用某人的姓名作为URL发出HTTP请求。

我们可能会想同时使用double价格和长度,或者将a string用作名称和URL。但是,这样做会破坏我们出色的类型系统,并允许这些滥用行为通过语言的静态检查。

将磅秒与牛顿秒混淆可能会在运行时产生不良结果


对于字符串,这尤其是一个问题。它们经常成为“通用数据类型”。

我们习惯于主要与计算机建立文本界面,并且经常将这些人机界面(UI)扩展为编程接口(API)。我们认为34.25是字符34.25。我们认为日期是字符05-03-2015。我们将UUID视为字符75e945ee-f1e9-11e4-b9b2-1697f925ec7b

但是这种思维模型会损害API抽象。

文字或语言,无论是书面还是口语,在我的思维机制中似乎没有任何作用。

艾尔伯特爱因斯坦

同样,文本表示形式在设计类型和API中不应扮演任何角色。当心string!(以及其他过于通用的“原始”类型)


类型传达“什么操作有意义”。

例如,我曾经在HTTP REST API的客户端上工作。正确完成的REST使用超媒体实体,该实体具有指向相关实体的超链接。在此客户端中,不仅键入了实体(例如,用户,帐户,订阅),而且还键入了到这些实体的链接(UserLink,AccountLink,SubscriptionLink)。这些链接只不过是包装器Uri而已,但是不同的类型使得无法尝试使用AccountLink来获取用户。如果一切正常Uri,甚至更糟,那么string这些错误只能在运行时发现。

同样,根据您的情况,您拥有的数据仅用于一种目的:识别Operation。不应将其用于其他任何事情,我们也不应尝试Operation使用已组成的随机字符串来识别。创建一个单独的类可以提高代码的可读性和安全性。


当然,所有的好东西都可以过量使用。考虑

  1. 它为您的代码增加了多少清晰度

  2. 多久使用一次

如果出于区别的目的而频繁地(在抽象意义上)在代码接口之间使用数据的“类型”,那么以冗长为代价,它是单独的类的很好的选择。


21
“字符串通常成为'通用'数据类型”-这就是嘲讽“字符串型语言”的绰号。
MSalters

@MSalters,哈哈,非常好。
Paul Draper 2015年

4
当然,当解释为日期时05-03-2015 意味着 什么?
CVn 2015年

10

您是否出于安全原因将简单类型包装在类中是一种好习惯吗?

有时。

这是您需要权衡使用a string而不是更具体的可能出现的问题的情况之一OperationIdentifier。他们的严重程度是多少?他们的可能性如何?

然后,您需要考虑使用其他类型的成本。使用有多痛苦?它要做多少工作?

在某些情况下,可以通过使用一种好的具体类型来节省时间和精力。在其他情况下,这样做是不值得的。

总的来说,我认为应该比今天做更多的事情。如果您的实体在您的域中具有重要意义,那么最好将其作为自己的类型,因为该实体更可能随业务而变化/发展。


10

我通常都同意,很多时候您应该为基元和字符串创建类型,但是由于上述答案建议在大多数情况下创建类型,因此,我将列出一些原因,当您不这样做时:

  • 性能。我必须在这里参考实际的语言。在C#中,如果客户端ID较短,并且将其包装在一个类中,则会造成很多开销:内存,因为在64位系统上它现在为8字节,并且自从现在分配到堆上以来,速度一直很高。
  • 在对类型进行假设时。如果客户端ID较短,并且您有某种以某种方式打包它的逻辑-通常您已经对类型进行了假设。在所有这些地方,您现在都必须破坏抽象。如果是一个点,那没什么大不了的;如果到处都是,您可能会发现使用原语的时间减少了一半。
  • 因为并非每种语言都有typedef。对于不使用的语言和已经编写的代码,进行这样的更改可能是一项艰巨的任务,甚至可能会引入错误(但是每个人都拥有覆盖面广的测试套件,对吗?)。
  • 在某些情况下,它会降低可读性。我该如何打印?我该如何验证?我应该检查null吗?是否所有问题都需要您深入研究类型的定义。

3
当然,如果包装器类型是结构而不是类,则short(例如)包装或展开的成本相同。(?)
MathematicalOrchid

1
封装自己的验证不是一个好的设计吗?还是创建一个辅助类型进行验证?
尼克·乌德尔

1
不必要的打包或乱涂是一种反模式。信息应透明地在应用程序代码中流动,除非明确和确实需要进行转换。类型是好的,因为它们通常将位于单个位置的少量优质代码替换为遍布整个系统的大量不良实现。
托马斯W

7

不,您不应该为“一切”定义类型(类)。

但是,正如其他答案所述,这样做通常是有用的。在编写,测试和维护代码时,由于缺少合适的类型或类,您应该有意识地(如果可能)发展一种过于摩擦的感觉。对我来说,太多摩擦的发生是当我想将多个基本值合并为一个值时,或者当我需要验证这些值时(即确定基本类型的所有可能值中的哪个对应于有效值)。 '隐含类型')。

我发现一些问题(例如您在问题中提出的问题)对我的代码中的过多设计负责。我养成了一种习惯,刻意避免编写多余的代码。希望您正在为代码编写良好的(自动化的)测试-如果您愿意,则可以轻松地重构代码并添加类型或类,如果这样做可以为正在进行的代码开发和维护带来净收益

Telastyn的答案Thomas Junk的答案都很好地说明了相关代码的上下文和用法。如果您在单个代码块(例如,方法,循环,using块)中使用值,那么只使用原始类型就可以了。如果您在其他许多地方重复使用一组值,则使用原始类型甚至更好。但是,使用一组值的频率越高,范围越广,并且该组值与原始类型表示的值的对应关系越不紧密,则应考虑将值封装在类或类型中的可能性就越大。


5

从表面上看,您所需要做的就是识别一个操作。

另外,您说一个操作应该做什么:

启动操作 [和]到监测其光洁度

您说的方式就好像这是“只是如何使用标识”,但我会说这些是描述操作的属性。在我看来,这听起来像是类型定义,甚至有一种非常适合的模式,称为“命令模式”。

它说

命令模式用于封装以后执行动作触发事件所需的所有信息。

我认为这与您要对操作进行的操作非常相似。(比较我在两个引号中加粗的短语)而不是返回字符串,而是返回Operation抽象意义上的的标识符,例如,指向oop中该类对象的指针。

关于评论

将此类称为命令,然后在其内部没有逻辑将是很困难的。

不,不会。请记住,模式是非常抽象的,实际上是如此抽象,以至于它们有点元。也就是说,他们经常抽象出程序本身而不是某些现实世界的概念。命令模式是函数调用的抽象。(或方法)好像有人在传递参数值之后立即在执行之前单击了暂停按钮,然后在执行之前立即按了该按钮,以便稍后恢复。

以下内容正在考虑oop,但是其背后的动机对于任何范例都应适用。我想说明为什么将逻辑放入命令中被认为是一件坏事。

  1. 如果您在命令中包含所有逻辑,那么它将很肿且混乱。您可能会想:“哦,所有这些功能都应该放在单独的类中。” 您可以从命令中重构逻辑。
  2. 如果命令中包含所有逻辑,那么将很难进行测试并妨碍测试。“天哪,为什么我必须废话使用这个命令?我只想测试该函数是否吐出1,我不想以后再调用它,我现在想对其进行测试!”
  3. 如果您在命令中具有所有逻辑,则在完成后进行报告并没有太大意义。如果您考虑默认情况下要同步执行的功能,则在完成执行时没有通知的意义。也许您正在启动另一个线程,因为您的操作实际上是将化身渲染为电影格式(可能需要在树莓派上使用其他线程),也许您必须从​​服务器中获取一些数据……无论如何,如果有原因,报告完成后返回,这可能是因为存在一些异步性(是不是一个词?)。我认为运行线程或联系服务器是您命令中不应包含的逻辑。这在某种程度上是一个元论证,也许是有争议的。如果您认为这没有任何意义,请在评论中告诉我。

回顾一下:命令模式使您可以将功能包装到一个对象中,稍后再执行。为了模块化(功能存在,无论是否通过命令执行都存在功能),可测试性(功能应该可以在没有命令的情况下进行测试)以及所有其他本质上表达编写好的代码需求的流行语,您不会放置实际的逻辑进入命令。


由于模式是抽象的,因此很难提出真实的良好隐喻。这是一个尝试:

“嘿,奶奶,您能在12点按一下电视上的录制按钮,这样我就不会错过第1频道的辛普森一家吗?”

我的祖母不知道按下记录按钮会发生什么技术上的变化。逻辑在其他地方(在电视中)。那是一件好事。该功能已封装,命令中隐藏了信息,它是API的用户,不一定是逻辑的一部分...哦,我再一次不休,我现在最好完成此编辑。


您是正确的,我还没有那样考虑过。但是命令模式并不是100%合适的,因为该操作的逻辑封装在另一个类中,并且我不能将其放在Command对象中。将此类称为命令,然后在其内部没有逻辑将是很可能的。
谢夫

这很有道理。考虑返回一个操作,而不是一个OperationIdentifier。这类似于您返回Task或Task <T>的C#TPL。@Ziv:您说“操作的逻辑封装在另一个类中”。但这真的意味着您也不能从这里提供它吗?
Moby Disk

@Ziv,我希望与众不同。看看我添加到答案中的原因,看看它们对您是否有意义。=)
空值

5

包装原始类型的想法,

  • 建立特定领域的语言
  • 通过传递不正确的值来限制用户所犯的错误

显然,在任何地方都很难做到,但不切实际,但是在需要的地方包装类型很重要,

例如,如果您有Order类,

Order
{
   long OrderId { get; set; }
   string InvoiceNumber { get; set; }
   string Description { get; set; }
   double Amount { get; set; }
   string CurrencyCode { get; set; }
}

查找订单的重要属性主要是OrderId和InvoiceNumber。金额和货币代码密切相关,如果有人更改了货币代码而不更改金额,则该订单将不再被视为有效。

因此,在这种情况下,只有包装OrderId,InvoiceNumber并为货币引入复合材料才有意义,包装说明可能没有意义。所以首选结果可能像

    Order
    {
       OrderIdType OrderId { get; set; }
       InvoiceNumberType InvoiceNumber { get; set; }
       string Description { get; set; }
       Currency Amount { get; set; }
    }

因此,没有理由包装所有东西,但实际上是重要的东西。


4

不受欢迎的意见:

您通常不应定义新类型!

定义一个新类型来包装原始或基本类有时称为定义伪类型。IBM 在这里将其描述为一种不好的做法(在这种情况下,它们专门针对滥用泛型)。

伪类型使通用库功能无用。

Java的Math函数可以使用所有数字原语。但是,如果您定义一个新的类Percentage(包装一个可以在0〜1范围内的double值),那么所有这些函数都将无用,并且需要被那些需要了解Percentage类的内部表示形式的类包装(甚至更糟) 。

伪型病毒传播

在制作多个库时,您通常会发现这些假类型具有病毒性。如果您在一个库中使用上述Percentage类,则需要在库边界处将其转换(失去创建该类型的所有安全性/含义/逻辑/其他原因),或者必须创建这些类其他库也可以访问。用您的类型感染新库,其中简单的double可能就足够了。

带走留言

只要您包装的类型不需要很多业务逻辑,我建议不要将其包装在伪类中。仅在存在严重业务限制的情况下才应包装类。在其他情况下,正确命名变量应该在传达含义方面大有帮助。

一个例子:

A uint可以完美地表示UserId,我们可以继续使用Java的内置运算符uint(例如==),并且不需要业务逻辑来保护用户ID的“内部状态”。


同意。所有的编程原理都很好,但是在实践中,您也必须使其工作起来
Ewan

如果您看到有任何借贷给英国最贫困人群的广告,您会发现以百分比为单位在0..1范围内包装
倍数的效果不佳

1
在C / C ++ / Objective C中,您可以使用typedef,它为现有的原始类型提供了一个新名称。另一方面,无论底层类型是什么,您的代码都应该工作,例如,如果我将OrderId从uint32_t更改为uint64_t(因为我需要处理40亿以上的订单)。
gnasher729

我不会为您的英勇而投票,但是伪类型和最高答案中定义的类型是不同的。这些是特定于业务的类型。伪类型是仍然通用的类型。
0fnt 2015年

@ gnasher729:C ++ 11还具有用户定义的文字,这些文字使用户的包装过程非常贴心:距离d = 36.0_mi + 42.0_km;msdn.microsoft.com/en-us/library/dn919277.aspx
damix911

3

与所有技巧一样,知道何时应用规则是技巧。如果您使用类型驱动的语言创建自己的类型,则会进行类型检查。因此,总的来说,这将是一个好的计划。

NamingConvention是可读性的关键。两者结合可以清楚地传达意图。
但是///仍然有用。

所以是的,我想说当它们的生命周期超出类边界时创建许多自己的类型。还请考虑同时使用Struct和和Class,而不总是使用Class。


2
什么///意思
2015年

1
///是C#中的文档注释,它允许intellisense在调用方法点显示签名的文档。考虑记录代码的最佳方法。可以使用工具进行养殖,并提供触及智能的行为。msdn.microsoft.com/zh-CN/library/tkxs89c5%28v=vs.71%29.aspx
phil soady 2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.