是否比实例变量更喜欢局部变量?


109

我正在使用的代码库经常使用实例变量在各种简单方法之间共享数据。最初的开发人员坚持认为这要遵循Bob / Robert Martin叔叔在Clean Code书中所述的最佳实践:“功能的第一个规则是它们应该很小。” 和“函数的理想参数个数为零(尼拉度)。(...)参数很难。它们需要很大的概念力。”

一个例子:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  private byte[] encodedData;
  private EncryptionInfo encryptionInfo;
  private EncryptedObject payloadOfResponse;
  private URI destinationURI;

  public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    checkNotNull(encryptedRequest);

    getEncodedData(encryptedRequest);
    getEncryptionInfo();
    getDestinationURI();
    passRequestToServiceClient();

    return cryptoService.encryptResponse(payloadOfResponse);
  }

  private void getEncodedData(EncryptedRequest encryptedRequest) {
    encodedData = cryptoService.decryptRequest(encryptedRequest, byte[].class);
  }

  private void getEncryptionInfo() {
    encryptionInfo = cryptoService.getEncryptionInfoForDefaultClient();
  }

  private void getDestinationURI() {
    destinationURI = router.getDestination().getUri();
  }

  private void passRequestToServiceClient() {
    payloadOfResponse = serviceClient.handle(destinationURI, encodedData, encryptionInfo);
  }
}

我将使用局部变量将其重构为以下内容:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    checkNotNull(encryptedRequest);

    byte[] encodedData = cryptoService.decryptRequest(encryptedRequest, byte[].class);
    EncryptionInfo encryptionInfo = cryptoService.getEncryptionInfoForDefaultClient();
    URI destinationURI = router.getDestination().getUri();
    EncryptedObject payloadOfResponse = serviceClient.handle(destinationURI, encodedData,
      encryptionInfo);

    return cryptoService.encryptResponse(payloadOfResponse);
  }
}

它更短,消除了各种琐碎方法之间的隐式数据耦合,并将变量范围限制为所需的最小值。尽管有这些好处,但我仍然似乎无法说服原始开发人员这种重构是有必要的,因为它似乎与上述Bob叔叔的做法相矛盾。

因此,我的问题是:偏爱局部变量而不是实例变量的客观科学依据是什么?我似乎不太愿意把手指放在上面。我的直觉告诉我,隐藏的联轴器是不好的,狭窄的范围要比广泛的范围好。但是支持这一点的科学是什么?

相反,我可能忽略了这种重构的不利之处吗?


评论不作进一步讨论;此对话已转移至聊天
maple_shaft

Answers:


170

偏向于局部变量而不是实例变量的客观科学依据是什么?

范围不是二进制状态,而是渐变。您可以按照从大到小的顺序对它们进行排序:

Global > Class > Local (method) > Local (code block, e.g. if, for, ...)

编辑:我所说的“类作用域”就是“实例变量”的意思。据我所知,它们是同义词,但我是C#开发人员,而不是Java开发人员。为了简洁起见,我将所有静态变量归入全局类别,因为静态变量不是问题的主题。

范围越小越好。理由是变量应尽可能地处于最小范围内。这有很多好处:

  • 它迫使您考虑当前班级的责任,并帮助您坚持SRP。
  • 它可以让您不必避免全局命名冲突,例如,如果两个或多个类有一个Name属性,你不会被强迫前缀他们像FooNameBarName...因此,保持你的变量名一样干净和简洁越好。
  • 它通过将可用变量(例如,用于Intellisense)限制为上下文相关的变量来整理代码。
  • 它启用了某种形式的访问控制,因此您的数据不会被您不认识的某个参与者操纵(例如,同事开发的另一个类)。
  • 当您确保这些变量的声明试图尽可能接近这些变量的实际用法时,它使代码更具可读性。
  • 大肆声明变量的范围过大通常表示开发人员不太了解OOP或如何实现它。看到范围广泛的变量是一个危险信号,表明OOP方法可能存在问题(无论是一般的开发人员还是特定的代码库)。
  • (凯文评论)使用当地人会迫使您以正确的顺序做事。在原始(类变量)代码中,您可能错误地移至passRequestToServiceClient()方法的顶部,并且仍然可以编译。对于本地人,只有传递未初始化的变量,您才可能犯该错误,这很明显很明显,您实际上并未这样做。

尽管有这些好处,但我仍然似乎无法说服原始开发人员这种重构是有必要的,因为它似乎与上述Bob叔叔的做法相矛盾。

相反,我可能忽略了这种重构的不利之处吗?

这里的问题是您对局部变量的参数是有效的,但是您还进行了其他更改,这些更改不正确,并导致建议的修复程序无法通过气味测试。

虽然我理解您的“没有类变量”的建议并且有其优点,但实际上您也删除了方法本身,这是完全不同的方法。这些方法应该保留下来,而应该更改它们以返回其值,而不是将其存储在类变量中:

private byte[] getEncodedData() {
    return cryptoService.decryptRequest(encryptedRequest, byte[].class);
}

private EncryptionInfo getEncryptionInfo() {
    return cryptoService.getEncryptionInfoForDefaultClient();
}

// and so on...

我确实同意您在该process方法中所做的事情,但是您应该一直在调用私有子方法,而不是直接执行它们的主体。

public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    checkNotNull(encryptedRequest);

    byte[] encodedData = getEncodedData();
    EncryptionInfo encryptionInfo = getEncryptionInfo();

    //and so on...

    return cryptoService.encryptResponse(payloadOfResponse);
}

您需要额外的抽象层,尤其是当您遇到需要多次重用的方法时。即使您当前不重用方法,也要在相关的地方创建子方法,即使这只是为了提高代码的可读性,也是一个好的习惯。

无论使用局部变量参数如何,我都立即注意到,您建议的修复程序比原始建议的可读性差得多。我确实承认,肆意使用类变量也会降低代码的可读性,但是与将所有逻辑都堆叠在一个(现在很复杂)的方法中相比,乍一看并没有。


评论不作进一步讨论;此对话已转移至聊天
maple_shaft

79

原始代码使用诸如参数之类的成员变量。当他说要​​最小化参数的数量时,他真正的意思是要最小化方法运行所需的数据量。将该数据放入成员变量并没有任何改善。


20
完全同意!这些成员变量只是隐式函数参数。事实上,它更糟糕的是,因为现在有这些变量的值和函数使用(从外部POV)之间没有明确的联系
雷米

1
我会说这不是本书的意思。多少个函数需要零输入数据才能运行?我认为这是本书中的其中一部分。
Qwertie

1
@Qwertie如果您有一个对象,则它处理的数据可能会完全封装在其中。像这样process.Start();myString.ToLowerCase()不应该看起来太怪异的功能(并且确实是最容易理解的功能)。
R. Schmitz

5
两者都有一个参数:隐式this。甚至有人可能会争辩说这个论点是明确给出的-在点之前。
BlackJack

47

其他答案已经很好地解释了局部变量的好处,因此剩下的只是您的问题的这一部分:

尽管有这些好处,但我仍然似乎无法说服原始开发人员这种重构是有必要的,因为它似乎与上述Bob叔叔的做法相矛盾。

那应该很容易。只需将他指向Bob叔叔的Clean Code中的以下引用:

没有副作用

副作用是谎言。您的函数可以做一件事,但也可以做其他隐藏的事情。有时它会对其类的变量进行意外更改。有时它将使它们成为传递给函数的参数或系统全局变量。无论哪种情况,它们都是不可靠的破坏性迷雾,常常导致奇怪的时间耦合和顺序依赖性。

(示例省略)

该副作用产生时间耦合。也就是说,只能在特定时间(换句话说,可以安全地初始化会话)调用checkPassword。如果调用顺序混乱,则会话数据可能会意外丢失。时间耦合令人困惑,尤其是当隐藏为副作用时。如果必须具有时间耦合,则应在函数名称中明确指出。在这种情况下,我们可以重命名函数checkPasswordAndInitializeSession,尽管这肯定违反了“做一件事”。

也就是说,鲍勃叔叔不仅说一个函数应该很少参数,他还说函数应尽可能避免与非本地状态进行交互。


3
在“完美世界”中,这将是列出的第二个答案。同事倾听理性的理想情况的第一个答案-但是,如果同事是狂热者,那么这里的答案将处理情况,而不会产生太多混乱。
R. Schmitz

2
为了使这个想法更加务实,可以简单地推断出本地状态比实例或全局状态容易得多。定义明确且紧密包含的可变性和副作用很少导致问题。例如,许多排序函数都是通过副作用在原地运行的,但这很容易在本地范围内推断出来。
Beefster

1
啊,好老的“在矛盾的公理中,有可能证明任何事情”。由于没有硬道理和谎言是IRL,因此任何教条都必须包含陈述相反观点(即矛盾)的陈述。
ivan_pozdeev

26

“这与某人叔叔的想法相矛盾”从来都不是一个好论点。决不。不要从叔叔那里汲取智慧,请自己考虑。

也就是说,实例变量应用于存储实际上需要永久或半永久存储的信息。这里的信息不是。没有实例变量,这很简单,因此可以使用它们。

测试:为每个实例变量编写文档注释。你能写出并非完全没有意义的东西吗?并向四个访问者写文档注释。他们同样毫无意义。

最糟糕的是,采用解密更改的方法,因为您使用了不同的cryptoService。不必更改四行代码,而必须用不同的变量替换四个实例变量,用不同的变量替换四个getter,并更改四行代码。

但是,当然,如果您通过代码行付款,则首选第一个版本。31行而不是11行。如果要调试其他东西,则需要三行来写,并且可以永久维护,以便在调试时进行读取,在需要更改时可以适应,如果您支持第二个cryptoService,则可以重复三行。

(遗漏了使用局部变量强制您以正确顺序进行调用的要点)。


16
为自己思考当然是件好事。但是,您的开篇段落实际上包括拒绝教师或前辈投入的学习者或大三学生;太过分了。
平坦的

9
@Flater在考虑了老师或前辈的意见后,看到他们的意见是错误的之后,驳回他们的意见是唯一正确的选择。最后,它不是关于解雇,而是关于质疑它,只有在确实证明是错误的情况下才予以解雇。
glglgl

10
@ChristianHackl:我全力以赴,不会盲目遵循传统主义,但我也不会盲目地摒弃它。答案似乎表明,为了自己的观点而避开已有的知识,这不是健康的做法。盲目听从别人的意见,不。当您不同意时质疑,显然是。完全驳回它,因为您不同意,不。在我阅读时,答案的第一段似乎至少暗示了后者。这很大程度上取决于depends对“为自己思考”的含义,这需要详细说明。
更加平坦

9
在共享的智慧平台上,这似乎有点
不对劲

4
从来都不是一个好的论据……我完全同意规则存在指导而不是规定。但是,关于规则的规则只是规则的子类,因此您
要说

14

偏向于局部变量而不是实例变量的客观科学依据是什么?我似乎不太愿意把手指放在上面。我的直觉告诉我,隐藏的联轴器是不好的,狭窄的范围比广泛的范围要好。但是支持这一点的科学是什么?

实例变量用于表示其宿主对象的属性,而不用于表示比对象本身范围更窄的计算线程特有的属性。似乎尚未涵盖的做出这种区分的一些原因围绕并发性和可重入性。如果方法通过设置实例变量的值来交换数据,则两个并发线程可以轻易破坏彼此的实例变量值,从而产生间歇性的,难以发现的错误。

即使只有一个线程也可能在这些方面遇到问题,因为依赖实例变量的数据交换模式很可能使方法不可重入。同样,如果使用相同的变量在不同的方法对之间传递数据,则存在一个风险,即即使执行非递归方法调用链的单个线程也将陷入围绕所涉及实例变量的意外修改而引起的错误中。

为了在这种情况下可靠地获得正确的结果,您需要使用单独的变量在每对方法之间进行通信,其中每一个方法调用另一个方法,或者使每个方法实现都考虑所有其他方法的所有实现细节。它直接或间接调用的方法。这是易碎的,并且缩放性很差。


7
到目前为止,这是唯一提及线程安全性和并发性的答案。考虑到问题中的特定代码示例,这真是太神奇了:SomeBusinessProcess的实例无法一次安全地处理多个加密请求。该方法public EncryptedResponse process(EncryptedRequest encryptedRequest)不同步,并发调用很可能破坏实例变量的值。这是一个很好的提出。
约书亚·泰勒

9

仅仅讨论一下process(...),就业务逻辑而言,您的同事的例子就更加清晰了。相反,您的反例不仅仅需要粗略地浏览即可提取任何含义。

话虽这么说,干净的代码既清晰又质量好-将本地状态扩展到更全球化的空间只是高级汇编,因此质量为零。

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  public EncryptedResponse process(EncryptedRequest request) {
    checkNotNull(encryptedRequest);

    return encryptResponse
      (routeTo
         ( destination()
         , requestData(request)
         , destinationEncryption()
         )
      );
  }

  private byte[] requestData(EncryptedRequest encryptedRequest) {
    return cryptoService.decryptRequest(encryptedRequest, byte[].class);
  }

  private EncryptionInfo destinationEncryption() {
    return cryptoService.getEncryptionInfoForDefaultClient();
  }

  private URI destination() {
    return router.getDestination().getUri();
  }

  private EncryptedObject routeTo(URI destinationURI, byte[] encodedData, EncryptionInfo encryptionInfo) {
    return serviceClient.handle(destinationURI, encodedData, encryptionInfo);
  }

  private void encryptResponse(EncryptedObject payloadOfResponse) {
    return cryptoService.encryptResponse(payloadOfResponse);
  }
}

此表示法消除了任何范围内对变量的需求。是的,编译器会生成它们,但重要的部分是它可以控制它们,从而使代码高效。同时也比较清晰。

只是命名上的一点。您需要有意义的最短名称,并在现有信息上进行扩展。即。destinationURI,类型签名已经知道“ URI”。


4
消除所有变量并不一定会使代码更易于阅读。
法老

使用无点样式完全消除所有变量en.wikipedia.org/wiki/Tacit_programming
Marcin

@Pharap是的,缺少变量并不能确保可读性。在某些情况下,它甚至使调试更加困难。关键是精心挑选的名称,清晰的表达方式可以非常清晰地传达想法,同时仍然有效。
Kain0_0

7

我将完全删除这些变量和私有方法。这是我的重构:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    return cryptoService.encryptResponse(
        serviceClient.handle(router.getDestination().getUri(),
        cryptoService.decryptRequest(encryptedRequest, byte[].class),
        cryptoService.getEncryptionInfoForDefaultClient()));
  }
}

对于私有方法,例如 router.getDestination().getUri()比更加清晰和可读getDestinationURI()。如果我在同一课程中两次使用同一行,我什至会重复一遍。换句话说,如果需要a getDestinationURI(),则它可能属于其他类,而不是SomeBusinessProcess类。

对于变量和属性,它们的共同需求是保留要在以后使用的值。如果该类没有用于属性的公共接口,则它们可能不应该是属性。最糟糕的类属性使用可能是通过副作用在私有方法之间传递值。

无论如何,该类只需要这样做process(),然后该对象将被丢弃,无需在内存中保留任何状态。进一步的重构潜力是将CryptoService从该类中删除。

基于评论,我想添加此答案是基于现实世界的实践。确实,在代码审查中,我首先要选择的是重构类并移出加密/解密工作。一旦完成,然后我会问是否需要方法和变量,它们是否正确命名等等。最终代码可能更接近于此:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;

  public Response process(Request request) {
    return serviceClient.handle(router.getDestination().getUri());
  }
}

使用上面的代码,我认为它不需要进一步的重构。与规则一样,我认为需要经验来知道何时以及何时不应用它们。规则并不是在所有情况下都适用的理论。

另一方面,代码审查对一段代码可以通过多长时间具有真正的影响。我的诀窍是减少代码并使其易于理解。变量名可能是讨论的重点,如果我可以删除它,那么审阅者甚至都不需要考虑它。


我的支持,尽管许多人会在这里犹豫。当然,有些抽象是有意义的。(顺便说一句,叫做“过程”的方法是可怕的。)但是这里的逻辑是最小的。然而,OP的问题在于整个代码风格,而且情况可能更复杂。
Joop Eggen

1
将其全部链接到一个方法调用中的一个明显问题是纯粹的可读性。如果您需要对一个给定的对象执行多个操作,那么它也不起作用。同样,这几乎是不可能调试的,因为您无法单步执行操作并检查对象。尽管这在技术层面上可行,但我不主张这样做,因为它极大地忽略了软件开发的非运行时方面。
更加平坦

@Flater我同意您的意见,我们不想将其应用于所有地方。我修改了答案,以阐明自己的实际立场。我想证明的是,实际上我们只在适当的时候应用规则。在这种情况下,链式方法调用很好,如果需要调试,我将为链式方法调用测试。
imel96

@JoopEggen是的,抽象很有意义。在该示例中,私有方法无论如何都不给出任何抽象,该类的用户甚至都不了解它们
imel96

1
@ imel96有趣的是,您可能是少数几个注意到ServiceClient和CryptoService之间的耦合的人之一,因此有必要将精力集中在将CS注入SC而不是SBP中,从而在更高的体系结构级别解决潜在的问题...这就是IMVHO这个故事的重点;在关注细节时跟踪大图太容易了。
vaxquis

4

Flater的答案涵盖了范围界定的问题,但是我认为这里还有另一个问题。

请注意,处理数据的功能和仅访问数据的功能之间存在差异。

前者执行实际的业务逻辑,而后者则通过添加更简单,更可重用的界面来节省类型并可能增加安全性。

在这种情况下,数据访问功能似乎无法保存类型,并且不会在任何地方重用(否则删除它们还会有其他问题)。因此,这些功能根本不应该存在。

通过仅在命名函数中保留业务逻辑,我们可以兼得两者(在Flater的答案imel96的答案之间某个位置):

public EncryptedResponse process(EncryptedRequest encryptedRequest) {

    byte[] requestData = decryptRequest(encryptedRequest);
    EncryptedObject responseData = handleRequest(router.getDestination().getUri(), requestData, cryptoService.getEncryptionInfoForDefaultClient());
    EncryptedResponse response = encryptResponse(responseData);

    return response;
}

// define: decryptRequest(), handleRequest(), encryptResponse()

3

第一件也是最重要的一件事情:鲍勃叔叔有时似乎像一位传教士,但指出他的规则有例外。

清洁代码的全部思想是提高可读性并避免错误。有几个规则互相冲突。

他关于函数的观点是,尼拉第函数是最好的,但是最多可以接受三个参数。我个人认为4也可以。

使用实例变量时,它们应构成一个连贯的类。这意味着,即使不是所有非静态方法,也应在许多方法中使用变量。

应该移动该类的许多地方未使用的变量。

我既不认为原始版本也不认为重构版本是最佳版本,@ Flater已经很好地说明了使用返回值可以做什么。它提高了可读性并减少了使用返回值的错误。


1

局部变量减小了范围,因此限制了变量的使用方式,因此有助于防止某些类别的错误,并提高了可读性。

实例变量减少了调用函数的方式,这也有助于减少某些错误类别,并提高了可读性。

在任何一个特定情况下,说一个是对的而另一个是错的可能是一个有效的结论,但作为一般性建议...

TL; DR:我认为您闻到过多热情的原因是,热情过高。


0

尽管以get ...开头的方法不应返回void,但在第一个解决方案中给出了方法中抽象级别的分隔。尽管第二个解决方案的范围更广,但仍然很难对方法中发生的事情进行推理。这里不需要分配局部变量。我将保留方法名称,并将代码重构为类似的内容:

public class SomeBusinessProcess {
  @Inject private Router router;
  @Inject private ServiceClient serviceClient;
  @Inject private CryptoService cryptoService;

  public EncryptedResponse process(EncryptedRequest encryptedRequest) {
    checkNotNull(encryptedRequest);

    return getEncryptedResponse(
            passRequestToServiceClient(getDestinationURI(), getEncodedData(encryptedRequest) getEncryptionInfo())
        );
  }

  private EncryptedResponse getEncryptedResponse(EncryptedObject encryptedObject) {
    return cryptoService.encryptResponse(encryptedObject);
  }

  private byte[] getEncodedData(EncryptedRequest encryptedRequest) {
    return cryptoService.decryptRequest(encryptedRequest, byte[].class);
  }

  private EncryptionInfo getEncryptionInfo() {
    return cryptoService.getEncryptionInfoForDefaultClient();
  }

  private URI getDestinationURI() {
    return router.getDestination().getUri();
  }

  private EncryptedObject passRequestToServiceClient(URI destinationURI, byte[] encodedData, EncryptionInfo encryptionInfo) {
    return serviceClient.handle(destinationURI, encodedData, encryptionInfo);
  }
}

0

两者都一样,并且性能差异不明显,因此我认为没有科学依据。然后归结为主观偏好。

而且我也倾向于比您的同事更喜欢您的方式。为什么?因为尽管有些书作者说了什么,但我认为它更容易阅读和理解。

两种方式都可以完成同一件事,但是他的方式更加分散。要阅读该代码,您需要在几个函数和成员变量之间来回切换。并非所有事物都凝聚在一个地方,您需要记住所有这些内容才能理解它。这是更大的认知负担。

相比之下,您的方法将所有内容打包得更加密集,但并没有使其难以渗透。您只需要一行一行地阅读它,而无需为了解它而记太多。

但是,如果他习惯于以这种方式对代码进行布局,我可以想象对他来说可能是另一回事。


事实证明,这确实是我理解流程的主要障碍。这个示例很小,但是另一个示例使用35(!)实例变量作为中间结果,而不计算包含依赖项的十几个实例变量。很难跟进,因为您必须跟踪早先已经设置的内容。有些甚至在以后被重用,这使它变得更加困难。幸运的是,这里提出的论点终于说服了我的同事同意重构。
亚历山大
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.