likes
comments
collection
share

记录一个因变量遮蔽引起的“友尽”级bug

作者站长头像
站长
· 阅读数 18

之前在翻译学习EOPL过程中回顾以前的代码时发现一个让人后背发凉的隐患,一种极其罕见、但是一旦出现就难以发现并可能造成非常大影响的bug,本文就记录下这个问题。

问题场景

下面来看一段常见的示例程序:

public class DemoActivity extends Activity {
    public static final String TAG = DemoActivity.class.getSimpleName();

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        findViewById(R.id.btn_test).setOnClickListener(new BaseClickListener() {
            @Override
            protected void onClicked(View v) {
                Log.i(TAG, "onClicked: clicked");
            }
        });
    }
}

这是一个防止快速点击的示例,这个程序中类DemoActivity包含了一个BaseClickListener的匿名内部类,然后这个类中引用了外部类的声明的变量TAG(好像是这样)。但事实上,BaseClickListener的实现是这样的:

public abstract class BaseClickListener implements View.OnClickListener {

    public static final String TAG = BaseClickListener.class.getSimpleName();

    private long lastClickTime = 0L;

    @Override
    public final void onClick(View v) {
        long currentTime = System.currentTimeMillis();
        if (currentTime - lastClickTime >= 500) {
            onClicked(v);
        } else {
            Log.d(TAG, "onClick: click to fast");
        }
        lastClickTime = currentTime;
    }

    protected abstract void onClicked(View v);
}

因此在DemoActivity中匿名内部类引用的其实是BaseClickListener类中声明的TAG。这里发生了变量遮蔽的现象,而且还是一种不可见的、“隐式”的遮蔽。这种遮蔽的存在使得外部文件的修改可以直接影响当前文件的语义。

试想,这两个类属于不同的模块,甚至不同的项目,使用BaseClickListener的人甚至有可能不会去看源码、甚至拿不到源码,而编写BaseClickListener的人更不会考虑DemoActivity的实现。一开始BaseClickListener类中可能没有TAG变量,DemoActivity中对TAG的引用指向DemoActivity.TAG,后来BaseClickListener引入了这个变量,这时候,不管是DemoActivity的开发者还是BaseClickListener的开发者都不知道这里出了问题,而DemoActivity的程序语义已经发生了改变。

上述示例程序中问题范围仅仅是打印日志,所以影响范围有限。但如果换成其他的变量,比如常见的idrootViewnamecontentcurrentTimecount等等等等,那么运气好的情况下直接编译不过,运气不好的情况程序崩溃甚至数据污染,而且这个问题需要通过跟踪变量引用才能发现,光肉眼看还可能看不出来。

我还没有在实际项目中遇到过这个问题,但如果真的遇到了,那我可能要跟我的同事“友尽”了,因此将其列为“友尽”级bug。

问题分析

上面问题产生原因如下:

  1. 这是“隐式”发生的遮蔽,还是无意识的遮蔽,且遮蔽跨越多文件;
  2. 变量/常量/方法声明定义与使用跨多文件,且引用采用简化形式,并非完全形式;

首先简要谈谈遮蔽(shadow)。(之所以简要,是因为这个概念结合作用域、上下文来讲比较容易讲透,这个另外在写文章总结)

遮蔽在现在大部分流行语言里都或多或少出现一点,除了上面展示的,还有例如下面javascript程序中典型的遮蔽:

function outer(x){
    function inner(x){
        {
            let x = 3
            console.log("x1:" + x)// x = 3
        }
        console.log("x2:" + x)// x = 2
    }
    console.log("x3:" + x)// x = 1
    inner(x + 1)
}

outer(1)

这种遮蔽还相对安全,因为是可见范围内发生的显式遮蔽。但是Java的类作用域与词法作用域结合使得可以发生隐式的遮蔽,只要出现内部类,不管是成员域还是方法甚至局部变量都有可能发生这类遮蔽。这是语言设计中的意外,对于语言使用者来说难以避免这类问题。

一点点建议

  • 最差的建议:避免使用内部类;
  • 次好的建议:对于类的成员域以及方法的访问采用全限定名,比如:OuterClass.this.name
  • 较好的建议:外部类可使用简写来引用成员域或方法,内部类则采用全名。