创建块只是为了减小变量的范围是否有意义?


38

我正在用Java编写程序,有一次我需要为密钥库加载密码。只是为了好玩,我尝试通过执行以下操作使密码尽可能短:

//Some code
....

KeyManagerFactory keyManager = KeyManagerFactory.getInstance("SunX509");
Keystore keyStore = KeyStore.getInstance("JKS");

{
    char[] password = getPassword();
    keyStore.load(new FileInputStream(keyStoreLocation), password);
    keyManager.init(keyStore, password);
}

...
//Some more code

现在,在这种情况下,我知道这有点愚蠢。我还可以做很多其他事情,其中​​大多数实际上更好(我根本不能使用变量)。

但是,我很好奇在某些情况下这样做不是那么愚蠢。我唯一想到的另一件事是,如果您想重用通用变量名,如counttemp,但是良好的命名约定和简短的方法使其不太可能有用。

在某种情况下,使用块来减小变量范围有意义吗?


13
@gnat ....什么?我什至不知道如何编辑我的问题,使其与那个欺骗对象不太相似。哪一部分使您相信这些问题是相同的?
法夸德勋爵(Lord Farquaad)'17

22
通常对此的答复是,您的匿名块应改为具有有效名称的方法,这将为您提供自动文档编制缩小所需范围。副手,我想不出提取方法并不比插入裸块更好的情况。
吉莲·芙丝

4
对于C ++,有两个类似的问题要问:创建代码块是不好的做法吗?带有花括号的结构代码。尽管C ++和Java在这方面有很大的不同,但是也许其中一个包含了这个问题的答案。
天宫院


4
@gnat为什么这个问题还要公开?这是我见过的最广泛的事情之一。它是在尝试一个规范的问题吗?
jpmc26

Answers:


34

首先,与基础机制进行对话:

在C ++范围中,==生存期b / c析构函数在范围的出口处调用。此外,在C / C ++中,重要的区别是我们可以声明局部对象。在C / C ++的运行时中,编译器通常会预先为该方法分配一个可能需要的堆栈框架,而不是在每个作用域(声明变量)的入口处分配更多的堆栈空间。因此,编译器正在折叠或展平范围。

C / C ++编译器可能会将堆栈存储空间重用于在使用期限上不冲突的本地人(通常,它会使用对实际代码的分析来确定此范围而不是范围,而b / c比范围更准确!)。

我提到C / C ++ b / c Java的语法,即花括号和作用域,至少部分是从该家族派生的。同时也因为C ++出现了问题注释。

相反,在Java中不可能有本地对象:所有对象都是堆对象,而所有本地/形式都是引用变量或原始类型(顺便说一句,对于静态对象也是如此)。

此外,在Java中,作用域和生存期不是完全相等的:进入/离开作用域主要是一个编译时概念,涉及可访问性和名称冲突。在Java中,在作用域退出方面,变量清除方面并没有真正发生任何事情。Java的垃圾回收确定对象的寿命(终点)。

Java的字节码机制(Java编译器的输出)也倾向于将在有限范围内声明的用户变量提升到方法的顶层,因为在字节码级别上没有范围功能(这类似于C / C ++对C / C ++的处理)。堆)。充其量,编译器可以重用局部变量槽,但是(与C / C ++不同)(仅当它们的类型相同时)。

(但是,要确保运行时底层的JIT编译器可以对字节码进行足够的分析,将相同的堆栈存储空间重用于两个不同类型(和不同插槽位置)的局部变量。)

说到程序员的优势,我倾向于同意其他人的观点,即(私有)方法即使只使用一次也是更好的构造。

但是,使用它与名称冲突不会有什么错。

在极少数情况下,当使单个方法成为负担时,我会这样做。例如,当switch在循环中使用大语句编写解释器时,我可能(取决于因素)为每个case案例引入一个单独的块,以使各个案例之间更加独立,而不是使每种案例采用不同的方法(每个案例仅被调用一次)。

(请注意,作为块中的代码,个别情况可以访问与封闭循环有关的“ break;”和“ continue;”语句,而作为方法,则需要返回布尔值,并且调用者必须使用条件语句才能访问这些控制流声明。)


非常有趣的答案(+1)!然而,C ++部分的一个补充。如果密码是块中的字符串,则字符串对象将在免费存储中的某个位置为可变大小部分分配空间。离开集团时,字符串将被销毁,从而导致可变部分的重新分配。但是由于它不在堆栈中,因此无法保证很快将其覆盖。因此,通过在字符串被销毁之前覆盖其每个字符来更好地管理机密性;-)
Christophe

1
@ErikEidt我试图提请注意说“ C / C ++”的问题,好像它实际上是一个“东西”,但事实并非如此:“在C / C ++的运行时中,编译器通常会分配一个堆栈框架对于可能需要的方法,“ C根本没有方法。它具有功能。这些是不同的,尤其是在C ++中。
tchrist

7
相反,在Java中不可能有本地对象:所有对象都是堆对象,所有本地/形式都是引用变量或原始类型(顺便说一句,对于静态也是如此)。 ”-注意,这是并非完全正确,尤其是对于方法局部变量而言。转义分析可以将对象分配到堆栈。
蜘蛛鲍里斯(Boris the Spider)

1
充其量,编译器可以重用局部变量槽,但是(与C / C ++不同)(仅当它们的类型相同时)。”简直是错误的。在字节码级别,可以根据需要分配和重新分配局部变量,唯一的限制是后续使用与最新分配项目的类型兼容。重用局部变量槽是常态,例如,使用{ int x = 42; String s="blah"; } { long y = 0; }long变量将重用两个变量的槽x以及s所有常用的编译器(javacecj)。
Holger

1
@蜘蛛鲍里斯(Boris the Spider):这是经常重复的口头陈述,与发生的事情并不完全匹配。转义分析可以启用标量化,这是将对象的字段分解为变量,然后对其进行进一步优化。其中一些可能会因为未使用而被淘汰,其他一些可能会与用于初始化它们的源变量折叠在一起,其他一些则映射到CPU寄存器。结果很少符合人们说“分配在堆栈上”时的想象。
Holger

27

我认为将块引入自己的方法会更加清楚。如果您将此块留在其中,我希望能看到一条注释,以阐明您为什么首先将块放置在那里。到那时,将方法调用initializeKeyManager()保持在更清洁的状态,该方法将password变量限制在方法的范围内,并显示您对代码块的意图。


2
我认为您的答案会受到关键用例的限制,这是重构期间的中间步骤。限制范围并知道您的代码在提取方法之前仍然可以工作可能很有用。
RubberDuck

16
@RubberDuck是的,但是在那种情况下,我们其余的人永远都不会看到它,因此我们的想法无关紧要。一个成人编码器和一个同意的编译器在自己的小隔间中私密地从事什么工作,这不是别人关心的。
candied_orange

@candied_orange我担心我不明白:(细心讲究吗?
doubleOrt

@doubleOrt我的意思是中间的重构步骤是真实且必要的,但是当然不必将其包含在任何人都可以查看的源代码控制中。只需完成重构,即可签入
。– candied_orange

@candied_orange哦,以为您在争论并且没有扩展RubberDuck的论点。
doubleOrt

17

它们具有严格的借用规则,在Rust中很有用。通常,在功能语言中,该块返回一个值。但是使用Java之类的语言?虽然这个主意看起来很优雅,但实际上很少使用,因此,从中看到的困惑将使限制变量范围的潜在清晰性相形见war。

在一种情况下,我发现这很有用-在switch声明中!有时您想在中声明变量case

switch (op) {
    case ADD:
        int result = a + b;
        System.out.printf("%s + %s = %s\n", a, b, result);
        break;
    case SUB:
        int result = a - b;
        System.out.printf("%s - %s = %s\n", a, b, result);
        break;
}

但是,这将失败:

error: variable result is already defined in method main(String[])
            int result = a - b;

switch 语句很奇怪-它们是控制语句,但是由于存在缺陷,因此不会引入作用域。

所以-您可以只使用块自己创建范围:

switch (op) {
    case ADD: {
        int result = a + b;
        System.out.printf("%s + %s = %s\n", a, b, result);
    } break;
    case SUB: {
        int result = a - b;
        System.out.printf("%s - %s = %s\n", a, b, result);
    } break;
}

6
  • 一般情况:

在某种情况下,仅使用块来减小变量范围有意义吗?

通常,保持方法简短是一种好习惯。减小某些变量的范围可能表明您的方法相当长,足以使您认为需要减小变量的范围。在这种情况下,值得为那部分代码创建一个单独的方法。

我会发现有问题的情况是那部分代码使用了不多的参数。在某些情况下,您可能会遇到一些小的方法,每个方法都带有很多参数(或者需要变通方法来模拟返回多个值)。解决该特定问题的方法是创建另一个类,该类保留您所使用的各种变量,并以相对较短的方法实现您要记住的所有步骤:这是使用Builder模式的典型用例。

也就是说,所有这些编码准则有时都可以宽松地应用。有时会出现奇怪的情况,如果您将代码保留在更长的方法中,而不是将其拆分为较小的子方法(或构建器模式),则代码可能更具可读性。通常,这适用于中等大小,相当线性的问题,并且拆分过多实际上会影响可读性(特别是如果您需要在太多方法之间跳转以了解代码的作用时)。

这是有道理的,但是非常罕见。

  • 您的用例:

这是您的代码:

KeyManagerFactory keyManager = KeyManagerFactory.getInstance("SunX509");
Keystore keyStore = KeyStore.getInstance("JKS");

{
    char[] password = getPassword();
    keyStore.load(new FileInputStream(keyStoreLocation), password);
    keyManager.init(keyStore, password);
}

您正在创建FileInputStream,但从未关闭它。

解决此问题的一种方法是使用try-with-resources语句。它的作用域为InputStream,如果您愿意,您也可以使用此块来减小其他变量的作用域(在一定程度上,因为这些行是相关的):

KeyManagerFactory keyManager = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
Keystore keyStore = KeyStore.getInstance("JKS");

try (InputStream fis = new FileInputStream(keyStoreLocation)) {
    char[] password = getPassword();
    keyStore.load(fis, password);
    keyManager.init(keyStore, password);
}

(顺便说一句,通常也最好使用getDefaultAlgorithm()代替SunX509。)

另外,尽管建议将代码块分成单独的方法通常是合理的。如果只需要3行,就很少值得这样做(如果有的话,创建单独的方法会降低可读性)。


“缩小某些变量的范围可能表示您的方法很长” –它可能是一个符号,但也可以这样做,并且应该使用IMHO 来记录使用局部变量值的时间。实际上,对于不支持嵌套作用域的语言(例如Python),如果相应的代码注释说明了变量的“寿命终止”,我将不胜感激。如果以后要重用同一变量,将很容易弄清楚它是否必须(或应该)在之前(但在“寿命终止”注释之后)用某个新值初始化。
强尼·迪

2

以我的拙见,对于生产代码而言,通常应该避免这种情况,因为除了执行不同任务的零星函数之外,您通常不愿意这样做。我倾向于在用于测试事物的一些原始代码中执行此操作,但是在生产代码中却没有发现这样做的诱惑,在生产代码中,我已经预先考虑了每个函数应该做什么,因为从那时起,该函数自然就会具有就其当地状态而言范围有限。

我从未真正看到过像这样使用匿名块的示例(不涉及条件,try交易的块等),以一种有意义的方式缩小了函数的作用域,而这并没有引起为什么它不能这样的问题如果实际上从匿名块的真正SE立场真正受益的话,可以将其进一步划分为范围更小的简单函数。通常折衷的代码会做很多松散相关或非常不相关的事情,而我们最想做到这一点。

举例来说,如果您尝试执行此操作以重用名为的变量count,则表明您正在计算两个完全不同的事物。如果变量名要短于count,那么将其绑定到函数的上下文对我来说是有意义的,这可能只是在计算一种类型的事物。然后,您可以立即查看该函数的名称和/或文档,请参阅count,并立即了解该函数在做什么的上下文中的含义,而无需分析所有代码。对于函数而言,我很难找到一个很好的论据来计算两个不同的事物,它们以使匿名作用域/块与替代方法相比更具吸引力的方式重用同一变量名。并不是说所有功能都只需要计数一件事。一世'重用同一个变量名来对两个或多个事物进行计数并使用匿名块来限制每个单独计数的范围的函数在工程上是有益的。如果函数简单明了,那么拥有两个不同名称的count变量并不是世界末日,第一个可能具有比理想情况更多的范围可见性行。缺少此类匿名块以进一步缩小其局部变量的最小范围,此类函数通常不是错误的来源。

不建议使用多余的方法

这并不是建议您为了缩小范围而强制创建方法。可以说这是坏事,也可能是坏事,我的建议不应该是需要笨拙的私有“帮助”方法,而不仅仅是匿名作用域。这比现在考虑代码太多,以及如何缩小变量范围,比考虑如何在接口级别上从概念上解决问题的方式自然地产生了清晰,短的局部函数状态可见性,而无需深层嵌套块和6+级别的压痕。我同意Bruno的看法,即您可以通过将3行代码强行插入一个函数中来妨碍代码的可读性,但这首先要假设您正在演化基于现有实现创建的函数,而不是在不纠结于实现的情况下设计功能。如果您采用后一种方法,那么我发现除了在给定方法中缩小变量范围外,对匿名块的用处不大,除非您热心地尝试通过几行无害的代码来缩小变量的范围这些匿名块的异国情调的引入可以说消除了它们带来的大量智力开销。

试图进一步缩小最小范围

如果将局部变量范围减小到绝对最小值是值得的,那么应该广泛接受如下代码:

ImageIO.write(new Robot("borg").createScreenCapture(new Rectangle(Toolkit.getDefaultToolkit().getScreenSize())), "png", new File(Db.getUserId(User.handle()).toString()));

...这会导致状态的可见性降至最低,甚至根本没有创建变量来引用它们。我不想冒犯教条,但我实际上认为,务实的解决方案是在可能的情况下避免匿名块,就像避免上面的代码行一样,如果它们在生产环境中看起来绝对必要,从正确性和在函数中保持不变性的角度出发,那么我绝对认为您应该重新审视如何将代码组织成函数并设计接口。自然地,如果您的方法长400行,并且变量的作用域对300行代码的可视性超出了需要,那可能是一个真正的工程问题,但是使用匿名块解决不一定是问题。

如果没有其他问题,那么到处使用匿名块是异国情调的,而不是惯用的,而异国情调的代码可能会在几年后被他人(如果不是您自己)讨厌。

缩小范围的实用性

减小变量范围的最终用途是使您能够正确地进行状态管理并使其保持正确状态,并使您能够轻松地推断出代码库中任何给定部分的功能-从而能够保持概念上的不变性。如果单个函数的本地状态管理是如此复杂,以至于您不得不在代码中要用匿名块强制缩小范围,而该匿名块并不意味着必须最终确定并且很好,那么这再次向我表明,该函数本身需要重新检查。如果您在推理局部函数范围内的变量的状态管理时遇到困难,请想象一下在推理整个类的每个方法都可以访问的私有变量时遇到的困难。我们不能使用匿名块来降低其可见性。对我来说,这可以帮助我们开始接受这样一种观点,即变量的范围往往比许多语言中理想情况下的范围要宽一些,前提是它不会失去控制变量的能力。从实用的角度来看,使用匿名块解决不了什么,我认为这是可以接受的。


作为最后的警告,正如我提到的,我仍然不时使用匿名块,通常在剪贴簿代码的主要入口点方法中。有人提到它是重构工具,我认为这很好,因为结果是中介的,打算进一步重构,而不是最终代码。我并没有完全放弃它们的用处,但是如果您试图编写被广泛接受,经过良好测试和正确的代码,我几乎看不到需要匿名块。痴迷于使用它们可以将注意力从使用自然的小块设计更清晰的功能上转移出来。

2

在某种情况下,仅使用块来减小变量范围有意义吗?

我认为动机是引入障碍的充分理由,是的。

正如Casey指出的那样,该块是“代码异味”,表示您最好将块提取到函数中。但是你可能不会。

John Carmark 在2007年就内联代码写了一份备忘录。他的总结

  • 如果仅从单个位置调用函数,请考虑对其进行内联。
  • 如果从多个地方调用一个函数,请查看是否有可能将工作安排在一个地方(可能带有标志)并内联。
  • 如果一个函数有多个版本,请考虑使用多个参数(可能是默认参数)制作一个函数。
  • 如果工作接近于纯粹的功能,很少引用全局状态,请尝试使其完全起作用。

因此,请思考,有目的地做出选择,并且(可选)留下证据来描述您做出选择的动机。


1

我的观点可能有争议,但是是的,我认为在某些情况下我会使用这种风格。但是,“仅减小变量的作用域”不是有效的参数,因为这已经在做。做某事只是做这不是一个正当的理由。另请注意,如果您的团队已经解决了这种语法是否是常规语法,则此解释没有任何意义。

我主要是C#程序员,但是我听说过在Java中按原样返回密码,char[]以减少密码在内存中的停留时间。在此示例中,它将无济于事,因为垃圾回收器被允许从不使用的那一刻起就开始收集数组,因此,密码是否离开作用域并不重要。我不是在争论完成数组后清除数组是否可行,但是在这种情况下,如果要这样做,对变量进行范围界定是有意义的:

{
    char[] password = getPassword();
    try{
        keyStore.load(new FileInputStream(keyStoreLocation), password);
        keyManager.init(keyStore, password);
    }finally{
        Arrays.fill(password, '\0');
    }
}

这实际上与try-with-resources语句相似,因为它可以对资源进行范围划分,并在完成后对其进行终结处理。再次请注意,我不是在争辩以这种方式处理密码,只是如果您决定这样做,则这种样式是合理的。

其原因是该变量不再有效。您已经创建,使用了它,并使它的状态无效以不包含任何有意义的信息。在此块之后使用该变量没有任何意义,因此对其进行范围划分是合理的。

我可以想到的另一个示例是,当您有两个具有相同名称和含义的变量,但先使用一个变量,然后又使用另一个变量,并且希望将它们分开。我已经用C#编写了这段代码:

{
    MethodBuilder m_ToString = tb.DefineMethod("ToString", MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final, typeofString, Type.EmptyTypes);
    var il = m_ToString.GetILGenerator();
    il.Emit(OpCodes.Ldstr, templateType.ToString()+":"+staticType.ToString());
    il.Emit(OpCodes.Ret);
    tb.DefineMethodOverride(m_ToString, m_Object_ToString);
}
{
    PropertyBuilder p_Class = tb.DefineProperty("Class", PropertyAttributes.None, typeofType, Type.EmptyTypes);
    MethodBuilder m_get_Class = tb.DefineMethod("get_Class", MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final, typeofType, Type.EmptyTypes);
    var il = m_get_Class.GetILGenerator();
    il.Emit(OpCodes.Ldtoken, staticType);
    il.Emit(OpCodes.Call, m_Type_GetTypeFromHandle);
    il.Emit(OpCodes.Ret);
    p_Class.SetGetMethod(m_get_Class);
    tb.DefineMethodOverride(m_get_Class, m_IPattern_get_Class);
}

您可能会说我可以ILGenerator il;在方法的顶部进行简单声明,但是我也不想将变量用于不同的对象(kinda函数方法)。在这种情况下,这些块使语法和视觉上分离执行的工作变得更加容易。它还表明,在该块之后,我已经完成了il,没有任何内容可以访问它。

反对此示例的一个论点是使用方法。也许可以,但是在这种情况下,代码不会那么长,并且将其分为不同的方法还需要传递代码中使用的所有变量。

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.