最后输入您的代码

哈勃!


前几天,我再次得到类型代码


if(someParameter.Volatilities.IsEmpty()) { // We have to report about the broken channels, however we could not differ it from just not started cold system. // Therefore write this case into the logs and then in case of emergency IT Ops will able to gather the target line Log.Info("Channel {0} is broken or was not started yet", someParameter.Key) } 

代码中有一个相当重要的功能:接收者非常想知道实际发生了什么。 确实,在一种情况下,我们的系统有问题,而在另一种情况下,我们只是在热身。 但是,该模型并没有给我们提供此信息(取悦经常是模型作者的发送者)。
此外,甚至“也许有问题”这一事实也源于Volatilities集合Volatilities空的事实。 在某些情况下这可能是正确的。


我敢肯定,代码中最有经验的开发人员会以“如果设置了标志的组合,那么我们将被要求制作A,B和C”的样式看到包含秘密知识的行(尽管模型本身不可见)。


从我的角度来看,这样节省的类结构在将来会对项目产生极大的负面影响,将其变成一系列的小问题,逐渐将或多或少方便的代码转变为遗留项目。


重要提示:在本文中,我提供了一些示例,这些示例适用于多个开发人员(而不是一个)的项目,并且这些项目将被更新和扩展至少5-10年。 如果项目只有一名开发人员五年,或者发布后没有计划进行任何更改,那么所有这些都没有意义。 这是合乎逻辑的,如果仅需要几个月的时间,就没有必要投资建立清晰的数据模型。


但是,如果您正在长时间玩-欢迎加入。


使用访客模式


通常,同一字段包含一个可能具有不同语义含义的对象(如示例中所示)。 但是,为了保存类,开发人员仅保留一种类型,并为其提供标志(或以“如果此处没有内容,则不计算任何内容”的方式注释)。 类似的方法可能会掩盖错误(这对项目不利,但对提供服务的团队来说很方便,因为从外部看不到错误)。 一个更正确的选择是使用界面+访问者,即使在电线的最远端也可以找出实际发生的情况。


在这种情况下,标题中的示例将变成以下形式的代码:


 class Response { public IVolatilityResponse Data { get; } } interface IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) } class VolatilityValues : IVolatilityResponse { public Surface Data; TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } class CalculationIsBroken : IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } interface IVolatilityResponseVisitor<TInput, TOutput> { TOutput Visit(VolatilityValues instance, TInput input); TOutput Visit(CalculationIsBroken instance, TInput input); } 

通过这种处理:


  • 我们需要更多代码。 A,如果我们想在模型中表达更多信息,则应该更多。
  • 由于这种继承,我们不能再将Response序列化为json / protobuf ,因为类型信息在那里丢失了。 我们将必须创建一个特殊的容器来执行此操作(例如,您可以为每个实现创建一个包含单独字段的类,但只填充其中一个)。
  • 扩展模型(即添加新类)需要扩展IVolatilityResponseVisitor<TInput, TOutput>接口,这意味着编译器将强制其在代码中得到支持。 程序员不会忘记处理新类型,否则项目将无法编译。
  • 由于是静态类型,因此我们不需要将文档与可能的字段组合等存储在某处。 我们描述了编译器和人员都可以理解的代码中所有可能的选项。 我们不会在文档和代码之间保持不同步,因为我们可以在没有第一个的情况下进行同步。

关于其他语言的继承限制


许多其他语言(例如ScalaKotlin )都具有允许您在特定条件下禁止从特定类型继承的关键字。 因此,在编译阶段,我们知道该类型的所有可能后代。


特别地,上面的示例可以像这样在Kotlin重写:


 class Response ( val data: IVolatilityResponse ) sealed class VolatilityResponse class VolatilityValues : VolatilityResponse() { val data: Surface } class CalculationIsBroken : VolatilityResponse() 

结果比代码少了一点,但是现在在编译过程中,我们知道所有可能的VolatilityResponse都与它在同一个文件中,这意味着以下代码将无法编译,因为我们没有遍历该类的所有可能值。


 fun getResponseString(response: VolatilityResponse) = when(response) { is VolatilityValues -> data.toString() } 

但是,值得记住的是,此类检查仅适用于功能调用。 下面的代码将编译没有错误:


 fun getResponseString(response: VolatilityResponse) { when(response) { is VolatilityValues -> println(data.toString()) } } 

并非所有原始类型都意味着同一件事


考虑一个相对典型的数据库开发。 最有可能的是,在代码中的某处您将具有对象标识符。 例如:


 class Group { public int Id { get; } public string Name { get; } } class User { public int Id { get; } public int GroupId { get; } public string Name { get; } } 

似乎是标准代码。 这些类型甚至与数据库中的类型匹配。 但是,问题是:下面的代码正确吗?


 public bool IsInGroup(User user, Group group) { return user.Id == group.Id; } public User CreateUser(string name, Group group) { return new User { Id = group.Id, GroupId = group.Id, name = name } } 

答案很可能不是,因为在第一个示例中我们正在比较用户Id和组Id 。 第二,我们错误地将GroupidUserid


奇怪的是,这很容易解决:只需获取类型GroupIdUserId等。 因此, User的创建将不再起作用,因为您的类型将不会收敛。 这非常酷,因为您可以告诉编译器有关模型的信息。


而且,具有相同参数的方法将为您正确工作,因为现在不再重复它们:


 public void SetUserGroup(UserId userId, GroupId groupId) { /* some sql code */ } 

但是,让我们回到标识符比较的示例。 稍微复杂一点,因为您必须防止编译器在构建过程中比较不可比的对象。


您可以执行以下操作:


 class GroupId { public int Id { get; } public bool Equals(GroupId groupId) => Id == groupId?.Id; [Obsolete("GroupId can be equal only with GroupId", error: true)] public override bool Equals(object obj) => Equals(obj as GroupId) public static bool operator==(GroupId id1, GroupId id2) { if(ReferenceEquals(id1, id2)) return true; if(ReferenceEquals(id1, null) || ReferenceEquals(id2, null)) return false; return id1.Id == id2.Id; } [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(object _, GroupId __) => throw new NotSupportedException("GroupId can be equal only with GroupId") [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(GroupId _, object __) => throw new NotSupportedException("GroupId can be equal only with GroupId") } 

结果:


  • 我们再次需要更多代码。 las,如果您想向编译器提供更多信息,则通常需要编写更多行。
  • 我们创建了新类型(我们将在下面讨论优化),有时会稍微降低性能。
  • 在我们的代码中:
    • 我们禁止混淆标识符。 现在,编译器和开发人员都清楚地看到不可能GroupId字段GroupId送到GroupId字段中
    • 我们禁止比较无与伦比。 我IEquitable比较代码还没有完全完成(实现IEquitable接口也是可取的,还必须实现GetHashCode方法),因此该示例并不需要复制到项目中。 但是,这个想法本身很明确:我们明确禁止编译器在比较错误类型时进行表达。 即 而不是说“这些果实相等吗?” 编译器现在看到“梨等于苹果吗?”。

有关sql和限制的更多信息


通常在我们的类型应用程序中,会引入易于验证的其他规则。 在最坏的情况下,许多函数如下所示:


 void SetName(string name) { if(name == null || name.IsEmpty() || !name[0].IsLetter || !name[0].IsCapital || name.Length > MAX_NAME_COLUMN_LENGTH) { throw .... } /**/ } 

也就是说,该函数接受相当广泛的输入类型,然后运行检查。 通常不是这样,因为:


  • 我们没有向程序员和编译器解释我们想要什么。
  • 在另一个类似的功能中,您将需要复制支票。
  • 当我们收到一个表示namestring ,我们并没有立即陷入困境,但是由于某种原因,后来的一些处理器指令继续执行。

正确的行为:


  • 创建一个单独的类型(在本例中,显然是Name )。
  • 在其中进行所有必要的验证和检查。
  • 尽快在Name包装string ,以尽快获取错误。

结果,我们得到:


  • 更少的代码,因为我们在构造函数中检出了name检查。
  • 快速失败策略-现在,在收到一个有问题的名称后,我们将立即失败,而不是调用更多方法,但仍然失败。 而且,我们没有发现类型过大的类型的数据库出现错误,而是立即发现开始处理这样的名称是没有意义的。
  • 如果函数签名为: void UpdateData(Name name, Email email, PhoneNumber number)对于我们来说,混淆参数已经比较困难。 毕竟,现在我们传递的不是三个相同的string ,而是三个不同的实体。

关于铸造的一点


引入了非常严格的类型,我们也不应忘记,将数据传输到Sql时,我们仍然需要获取真实的标识符。 在这种情况下,略微更新包裹一个string的类型是合乎逻辑的:


  • 添加以下形式的interface IValueGet<TValue>{ TValue Wrapped { get; } }实现: interface IValueGet<TValue>{ TValue Wrapped { get; } } interface IValueGet<TValue>{ TValue Wrapped { get; } } 。 在这种情况下,在Sql的转换层中,我们可以直接获取值
  • 您可以创建一个抽象祖先,并从中继承其余部分,而不是在代码中创建一堆或多或少相同的类型。 结果是以下形式的代码:

 interface IValueGet<TValue> { TValue Wrapped { get; } } abstract class BaseWrapper : IValueGet<TValue> { protected BaseWrapper(TValue initialValue) { Wrapped = initialValue; } public TValue Wrapped { get; private set; } } sealed class Name : BaseWrapper<string> { public Name(string value) :base(value) { /*no necessary validations*/ } } sealed class UserId : BaseWrapper<int> { public UserId(int id) :base(id) { /*no necessary validations*/ } } 

性能表现


说到创建大量类型,您经常会遇到两个辩证的论点:


  • 类型,嵌套和il代码越多,软件的速度就越慢,因为jit很难优化程序。 因此,这种严格的打字会导致项目严重中断。
  • 包装越多,应用程序占用的内存就越多。 因此,添加包装器将严重增加RAM需求。

严格来说,这两个论点经常是没有事实的,但是:


  • 实际上,在同一Java上的大多数应用程序中,字符串(和字节数组)占用主内存。 也就是说,创建包装通常对于最终用户而言不太可能引起注意。 但是,由于这种类型的键入,我们获得了一个重要的优点:在分析内存转储时,您可以评估每种类型对内存的贡献。 毕竟,您不仅看到遍布整个项目的匿名行列表。 相反,我们可以了解哪些对象类型更大。 另外,由于只有包装器才能容纳字符串和其他大型对象,因此您更容易了解每种特定包装器类型对共享内存的贡献。
  • 关于jit优化的论点部分正确,但还不完整。 实际上,由于严格的键入,您的软件开始摆脱了功能入口处的众多检查。 检查所有模型的设计是否适当。 因此,通常情况下,您的支票会更少(仅需要正确的类型就足够了)。 此外,由于将检查转移到构造函数中,并且不会被代码抹黑,因此确定哪个检查确实需要时间变得更加容易。
  • 不幸的是,在本文中,我无法进行全面的性能测试,该测试将仅使用intstring和其他原始类型的项目与大量微型类型和经典开发进行了比较。 主要原因是为此,您必须首先为测试创建一个典型的粗体项目,然后证明该特定项目是典型的项目。 第二点,一切都很复杂,因为在现实生活中,项目确实不同。 但是,进行综合测试会很奇怪,因为,正如我已经说过的那样,根据我的测量,在企业应用程序中创建微型对象通常会留下可忽略的资源(处于测量错误的水平)。

如何优化由大量此类微型类型组成的代码。


重要提示:仅当您收到肯定的事实是微型类型会使应用程序变慢时,才应进行此类优化。 以我的经验,这种情况是相当不可能的。 更有可能的是, 同一记录器会使减速 ,因为每个操作都在等待刷新到磁盘(使用M.2 SSD的开发人员计算机上的所有内容都可以接受,但是使用旧HDD的用户会看到完全不同的结果)。


但是,这些技巧本身:


  • 使用有意义的类型而不是引用类型。 如果Wrapper也可以处理重要类型,这将很有用,这意味着从理论上讲,您可以将所有必要的信息传递给堆栈。 尽管应该记住,仅当您的代码确实由于微类型而确实遭受频繁的GC困扰时,才有可能实现加速。
    • .Net中struct可能导致频繁装箱/拆箱。 同时,这样的结构可能在Dictionary / Map集合中需要更多的内存(因为数组中分配了一个空白)。
    • Kotlin / Scala的inline类型的适用性有限。 例如,您不能在其中存储多个字段(有时对于缓存ToString / GetHashCode值很有用)。
    • 许多优化器能够在堆栈上分配内存。 特别是.Net 对小型临时对象执行此 操作,Java中的GraalVM可以在堆栈上分配一个对象,但是如果必须返回该对象,则将其复制到堆中(适用于条件丰富的代码)。
  • 使用对象的实习(即,尝试获取现成的,预先创建的对象)。
    • 如果构造函数有一个参数,那么您可以仅创建一个缓存,其中键是此参数,而值是先前创建的对象。 因此,如果对象的种类很少,则可以简单地重复使用现成的对象。
    • 如果一个对象有多个参数,则可以简单地创建一个新对象,然后检查它是否在缓存中。 如果有类似的,最好返回已经创建的一个。
    • 由于必须对所有参数执行Equals / GetHashCode ,因此这种方案会减慢设计人员的工作。 但是,如果您缓存散列的值,它还可以加速对象的将来比较,因为在这种情况下,如果它们不同,则对象也将不同。 相同的对象通常具有一个链接。
    • 但是,由于GetHashCode / Equals更快,此优化将加快程序运行速度(请参见上文)。 另外,新对象(但是位于缓存中)的生存期将急剧下降,因此它们只能进入第0代。
  • 创建新对象时,请检查输入参数,并且不要进行调整。 尽管事实上该建议经常出现在编码风格的段落中,但实际上,它可以使您提高程序的效率。 例如,如果您的对象需要仅包含BIG LETTERS的字符串,则通常使用两种方法进行检查:或者从参数中ToUpperInvariant ,或者在循环中检查所有字母都大。 在第一种情况下,保证创建新行,在第二种情况下,保证创建一个最大迭代器。 结果,您节省了内存(但是,在两种情况下,仍将检查每个字符,以便仅在较少的垃圾回收的情况下提高性能)。

结论


我将再次从标题中重提重点:本文中描述的所有内容在已经开发并使用了多年的大型项目中都是有意义的。 在那些有意义的事情中,减少支持成本并减少添加新功能的成本。 在其他情况下,通常最合理的是尽可能快地制造产品,而不必担心测试,模型和“好的代码”。


但是,对于长期项目,使用最严格的类型是合理的,其中在模型中我们可以严格描述原则上可能的值。


如果您的服务有时可以返回无法正常工作的结果,请在模型中表达该结果,并将其明确显示给开发人员。 不要在文档中添加带有说明的一千个标志。


如果您的类型在程序中可以相同,但是在业务本质上不同,则将它们定义为完全不同。 即使它们的字段类型相同,也不要混合它们。


如果您对生产率有疑问,请采用科学的方法并进行测试(或更好的方法是,请独立的人员进行检查)。 在这种情况下,您实际上将加快程序的进度,而不仅仅是浪费团队的时间。 但是,情况也相反:如果怀疑您的程序或库运行缓慢,请进行测试。 无需说一切都很好,只需显示数字即可。

Source: https://habr.com/ru/post/zh-CN462655/


All Articles