我已经习惯通过引入编译错误来进行一些重构。例如,如果我想从我的类中删除一个字段并将其作为某些方法的参数,我通常会先删除该字段,这会导致类的编译错误。然后我会将参数引入我的方法,这会破坏调用者。等等。这通常会给我一种安全感。我实际上还没有读过任何关于重构的书籍,但我曾经认为这是一种相对安全的方式。但我想知道,它真的安全吗?或者这是一种糟糕的做事方式?
13 回答
我在重构时从不依赖简单的编译,代码可以编译但可能会引入错误。
我认为最好只为要重构的方法或类编写一些单元测试,然后通过在重构后运行测试,您将确保没有引入任何错误。
我不是说去测试驱动开发,只是编写单元测试以获得你需要重构的必要信心。
这是静态编译语言的一种常见且有用的技术。您正在做的事情的一般版本可以表述如下:
当您对可能使该模块的客户端中的某些用途无效的模块进行更改时,请以导致编译时错误的方式进行初始更改。
有多种推论:
如果方法、函数或过程的含义发生了变化,而类型也没有变化,则更改名称。(当您仔细检查并修复所有用途时,您可能会改回名称。)
如果将新案例添加到数据类型或将新文字添加到枚举,请更改所有现有数据类型构造函数或枚举文字的名称。(或者,如果你有幸拥有一个编译器来检查案例分析是否详尽,那么还有更简单的方法。)
如果您正在使用一种重载语言,请不要只更改一个变体或添加一个新变体。您冒着以不同方式静默解决重载的风险。如果您使用重载,则很难让编译器以您希望的方式为您工作。我知道处理重载的唯一方法是对所有用途进行全局推理。如果您的 IDE 不能帮助您,您必须更改所有重载变体的名称。不愉快。
您真正要做的是使用编译器来帮助您检查代码中可能需要更改的所有位置。
我看不出有什么问题。它是安全的,只要您在编译之前不提交更改,它就不会产生长期影响。此外,Resharper 和 VS 有一些工具可以让你的过程更轻松一些。
您在 TDD 的另一个方向上使用类似的过程 - 您编写的代码可能没有定义方法,这导致它无法编译,然后您编写足够的代码来编译(然后通过测试,等等......)
当您准备阅读有关该主题的书籍时,我推荐 Michael Feather 的“有效地使用旧代码”。(非作者添加:也是福勒的经典著作《重构》——重构网站可能会有用。)
他谈到在进行更改之前识别您正在工作的代码的特征,并进行他所谓的临时重构。那就是通过反思找到代码的特征,然后把结果扔掉。
您正在做的是将编译器用作自动测试。它将测试您的代码是否可以编译,但如果行为由于重构或是否有任何副作用而改变,则不会。
考虑这个
class myClass {
void megaMethod()
{
int x,y,z;
//lots of lines of code
z = mysideEffect(x)+y;
//lots more lines of code
a = b + c;
}
}
你可以重构出添加
class myClass {
void megaMethod()
{
int a,b,c,x,y,z;
//lots of lines of code
z = addition(x,y);
//lots more lines of code
a = addition(b,c);
}
int addition(int a, b)
{
return mysideaffect(a)+b;
}
}
这会起作用,但第二个附加项在调用该方法时会出错。除了编译之外,还需要进一步的测试。
很容易想到一个例子,其中由编译器错误进行的重构会默默地失败并产生意想不到的结果。
想到的几个案例:(我假设我们在谈论 C++)
- 将参数更改为具有默认参数的其他重载的函数。重构之后,参数的最佳匹配可能不是您所期望的。
- 具有强制转换运算符或非显式单参数构造函数的类。根据所涉及的星座,更改、添加、删除或更改其中任何一个的参数都可以更改被调用的最佳匹配。
- 更改虚函数而不更改基类(或以不同方式更改基类)将导致调用被定向到基类。
仅当您绝对确定编译器将捕获需要进行的每一个更改时,才应使用依赖编译器错误。我几乎总是倾向于怀疑这一点。
我只想补充一下这里的所有智慧,还有一种情况可能不安全。反射。这会影响 .NET 和 Java 等环境(当然还有其他环境)。您的代码可以编译,但是当反射尝试访问不存在的变量时仍然会出现运行时错误。例如,如果您使用像 Hibernate 这样的 ORM 并且忘记更新映射 XML 文件,这可能很常见。
在整个代码文件中搜索特定变量/方法名称可能会更安全一些。当然,它可能会带来很多误报,所以它不是一个通用的解决方案;你也可以在你的反射中使用字符串连接,这也会使这无用。但这至少离安全更近了一步。
我不认为有一个 100% 万无一失的方法,除了手动浏览所有代码。
我认为这是一种非常常见的方式,因为它会找到对该特定事物的所有引用。然而,现代 IDE(如 Visual Studio)具有查找所有引用的功能,这使得这变得不必要。
但是,这种方法有一些缺点。对于大型项目,编译应用程序可能需要很长时间。另外,不要长时间这样做(我的意思是,尽快让事情恢复正常)并且一次不要做超过一件事,因为你可能会忘记你修补的正确方法第一次。
这是一种方式,如果不知道您要重构的代码是什么样的以及您做出的选择,就无法明确说明它是安全还是不安全。
如果它对你有用,那么没有理由仅仅为了改变而改变,但是当你有时间阅读这里的资源时,可能会给你新的想法,随着时间的推移你可能想要探索这些想法。
如果您使用的是 dotnet 语言之一,您可以考虑的另一种选择是使用 Obsolete 属性标记“旧”方法,这将引入所有编译器警告,但如果有代码超出您的范围,仍然保留代码可调用控制(例如,如果您正在编写 API,或者如果您不使用 VB.Net 中的严格选项)。您可以愉快地重构,让过时的版本调用新版本;举个例子:
public string Username
{
get
{
return this.userField;
}
set
{
this.userField = value;
}
}
public int Login()
{
/* do stuff */
}
变成:
[ObsoleteAttribute()]
public string Username
{
get
{
return this.userField;
}
set
{
this.userField = value;
}
}
[ObsoleteAttribute("Replaced by Login(username, password)")]
public int Login()
{
Login(Username, Pasword);
}
public int Login(string username, string password)
{
/* do stuff */
}
反正我就是这样的
这是一种常见的方法,但结果可能会因您的语言是静态的还是动态的而异。
在静态类型语言中,这种方法是有意义的,因为您引入的任何差异都会在编译时被捕获。但是,动态语言通常只会在运行时遇到这些问题。这些问题不会被编译器捕获,而是被您的测试套件捕获;假设你写了一个。
我的印象是您正在使用 C# 或 Java 之类的静态语言,因此请继续使用这种方法,直到您遇到某种表明您应该采取其他方式的重大问题。
我进行通常的重构,但仍然通过引入编译器错误来进行重构。我通常在更改不是那么简单并且此重构不是真正的重构(我正在更改功能)时执行它们。这些编译器错误给了我一些地方,我需要查看并进行一些比名称或参数更改更复杂的更改。
这听起来类似于测试驱动开发中使用的绝对标准方法:编写引用不存在的类的测试,因此使测试通过的第一步是添加类,然后是方法,等等。有关详尽的 Java 示例,请参阅Beck 的书。
你的重构方法听起来很危险,因为你没有任何安全测试(或者至少你没有提到你有任何测试)。您可能创建的编译代码实际上并不能满足您的需求,或者会破坏应用程序的其他部分。
我建议您在实践中添加一条简单的规则:仅在单元测试代码中进行非编译更改。这样,您可以确保每次修改都至少有一个本地测试,并且在进行修改之前,您会在测试中记录修改的意图。
顺便说一句,Eclipse 使这种“失败、存根、写入”方法在 Java 中变得异常简单:每个不存在的对象都为您标记,并且 Ctrl-1 加上一个菜单选项告诉 Eclipse 为您编写一个(可编译的)存根!我很想知道其他语言和 IDE 是否提供类似的支持。
从某种意义上说,它是“安全的”,因为在经过充分编译时检查的语言中,它会强制您更新对已更改内容的所有实时引用。
如果你有条件编译的代码,它仍然可能出错,例如如果你使用了 C/C++ 预处理器。因此,如果适用,请确保在所有可能的配置和所有平台上进行重建。
它不会消除测试更改的需要。如果您向函数添加了参数,则编译器无法告诉您在更新该函数的每个调用站点时提供的值是否是正确的值。如果你删除了一个参数,你仍然会犯错误,例如,更改:
void foo(int a, int b);
到
void foo(int a);
然后将呼叫更改为:
foo(1,2);
到:
foo(2);
这编译得很好,但它是错误的。
就个人而言,我确实使用编译(和链接)失败作为搜索代码以获取对我正在更改的函数的实时引用的一种方式。但是您必须记住,这只是一种节省劳动力的设备。它不保证生成的代码是正确的。