【外评】好的重构与不好的重构

这些年来,我雇佣过很多开发人员。他们当中有很多人都坚信我们的代码需要大量重构。但问题是:几乎在每一个案例中,其他开发人员都发现他们新重构的代码更难理解和维护。同时,代码的运行速度也普遍较慢,漏洞较多。

现在,别误会我的意思。重构本质上并不是坏事。它是保持代码库健康的关键部分。问题是,糟糕的重构就是糟糕。而且,在试图让事情变得更好的同时,我们很容易掉入让事情变得更糟的陷阱。

因此,让我们来看看怎样的重构才是好的重构,怎样的重构才是坏的重构,以及如何避免成为人人都害怕在代码库附近看到的开发人员。

重构的好坏与丑陋

抽象可以是好的。抽象可以是坏的。关键在于知道何时以及如何应用抽象。让我们来看看一些常见的陷阱以及如何避免它们。

1. 大幅改变编码风格

我见过的最常见的错误之一就是开发人员在重构过程中完全改变了编码风格。这种情况通常发生在来自不同背景或对特定编程范式有强烈意见的人身上。

让我们来看一个例子。假设我们有一段代码需要清理:

之前:

// 🫤 this code could be cleaner
function processUsers(users: User[]) {
  const result = [];
  for (let i = 0; i < users.length; i++) { if (users[i].age >= 18) {
      const formattedUser = {
        name: users[i].name.toUpperCase(),
        age: users[i].age,
        isAdult: true
      };
      result.push(formattedUser);
    }
  }
  return result;
}

糟糕的重构:

import * as R from 'ramda';

// 🚩 adopted a completely different style + library
const processUsers = R.pipe(
  R.filter(R.propSatisfies(R.gte(R.__, 18), 'age')),
  R.map(R.applySpec({
    name: R.pipe(R.prop('name'), R.toUpper),
    age: R.prop('age'),
    isAdult: R.always(true)
  }))
);

虽然这个重构版本可能会吸引函数式编程爱好者,但它引入了一个新库(Ramda)和一种完全不同的编码风格。对于不熟悉这种方法的团队来说,维护起来可能是一场噩梦。

好的重构:

// ✅ cleaner and more conventional 
function processUsers(users: User[]): FormattedUser[] {
  return users
    .filter(user => user.age >= 18)
    .map(user => ({
      name: user.name.toUpperCase(),
      age: user.age,
      isAdult: true
    }));
}

该版本改进了原始代码,使用了更多惯用的 JavaScript 方法,如 filtermap。它更加简洁易读,但并没有引入全新的范式或外部依赖关系。

2. 不必要的抽象

我曾经雇佣过一个人,他在不理解底层代码的情况下添加了大量新的抽象。他们开始将不应该分组的东西分组,并且随着时间的推移(故意)产生分歧。他们合并了一些不应该合并的配置(不同的应用程序接口需要不同的配置)。

之前:

// 🫤 this code could be cleaner
function processUsers(users: User[]) {
  const result = [];
  for (let i = 0; i < users.length; i++) {
    if (users[i].age >= 18) {
      const formattedUser = {
        name: users[i].name.toUpperCase(),
        age: users[i].age,
        isAdult: true
      };
      result.push(formattedUser);
    }
  }
  return result;
}

糟糕的重构:

// 🚩 there are way more layers and abstractions here than necessary
class UserProcessor {
  private users: User[];

  constructor(users: User[]) {
    this.users = users;
  }

  public process(): FormattedUser[] {
    return this.filterAdults().formatUsers();
  }

  private filterAdults(): UserProcessor {
    this.users = this.users.filter(user => user.age >= 18);
    return this;
  }

  private formatUsers(): FormattedUser[] {
    return this.users.map(user => ({
      name: this.formatName(user.name),
      age: user.age,
      isAdult: true
    }));
  }

  private formatName(name: string): string {
    return name.toUpperCase();
  }
}

const processUsers = (users: User[]): FormattedUser[] => {
  return new UserProcessor(users).process();
};

这种重构引入了一个具有多个方法的类,看起来似乎更 “面向对象”,但实际上更复杂,也更难一目了然。

好的重构:

// ✅ cleaner and more conventional 
const isAdult = (user: User): boolean => user.age >= 18;

const formatUser = (user: User): FormattedUser => ({
  name: user.name.toUpperCase(),
  age: user.age,
  isAdult: true
});

function processUsers(users: User[]): FormattedUser[] {
  return users.filter(isAdult).map(formatUser);
}

该版本将逻辑分解为可重复使用的小函数,而不会引入不必要的复杂性。

3. 增加不一致性

我曾见过这样的情况:开发人员更新代码库的一部分,使其工作方式与其他部分完全不同,试图使其变得 “更好”。这往往会给其他开发人员带来困惑和挫败感,因为他们不得不在不同风格之间进行上下文切换。

比方说,我们有一个 React 应用程序,其中我们始终使用 React Query 来获取数据:

// Throughout the app
import { useQuery } from 'react-query';

function UserProfile({ userId }) {
  const { data: user, isLoading } = useQuery(['user', userId], fetchUser);

  if (isLoading) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

现在,想象一下开发人员决定只在一个组件中使用 Redux 工具包:

// One-off component
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchPosts } from './postsSlice';

function PostList() {
  const dispatch = useDispatch();
  const { posts, status } = useSelector((state) => state.posts);

  useEffect(() => {
    dispatch(fetchPosts());
  }, [dispatch]);

  if (status === 'loading') return <div>Loading...</div>;
  return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}

这种不一致性令人沮丧,因为它仅仅为一个组件引入了完全不同的状态管理模式。

更好的方法是坚持使用 React Query:

// Consistent approach
import { useQuery } from 'react-query';

function PostList() {
  const { data: posts, isLoading } = useQuery('posts', fetchPosts);

  if (isLoading) return <div>Loading...</div>;
  return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>;
}

该版本保持了一致性,使用 React Query 在整个应用程序中获取数据。它更简单,不需要其他开发人员只为一个组件学习新的模式。

请记住,代码库的一致性非常重要。如果您需要引入一种新模式,请首先考虑如何获得团队的认同,而不是制造一次性的不一致。

4. 重构前不了解代码

我见过的最大问题之一就是在学习代码时,为了学习而重构代码。这是一个可怕的想法。我看到过这样的评论:你应该用 6-9 个月的时间来学习一段代码。否则,你很可能会产生错误、影响性能等。

之前:

// 🫤 a bit too much hard coded stuff here
function fetchUserData(userId: string) {
  const cachedData = localStorage.getItem(`user_${userId}`);
  if (cachedData) {
    return JSON.parse(cachedData);
  }

  return api.fetchUser(userId).then(userData => {
    localStorage.setItem(`user_${userId}`, JSON.stringify(userData));
    return userData;
  });
}

糟糕的重构:

// 🚩 where did the caching go?
function fetchUserData(userId: string) {
  return api.fetchUser(userId);
}

重构者可能会认为他们在简化代码,但实际上他们已经删除了一个重要的缓存机制,而该机制是为了减少 API 调用和提高性能而设置的。

好的重构:

// ✅ cleaner code preserving the existing behavior
async function fetchUserData(userId: string) {
  const cachedData = await cacheManager.get(`user_${userId}`);
  if (cachedData) {
    return cachedData;
  }

  const userData = await api.fetchUser(userId);
  await cacheManager.set(`user_${userId}`, userData, { expiresIn: '1h' });
  return userData;
}

这次重构在保持缓存行为的同时,还可能通过使用更复杂的过期缓存管理器来改进缓存行为。

5. 了解业务背景

我曾带着可怕的遗留代码包袱加入一家公司。我领导了一个项目,将一家电子商务公司迁移到一个新的、现代的、更快的、更好的技术……angular.js。

结果发现,这家公司非常依赖搜索引擎优化,而我们构建了一个缓慢而臃肿的单页面应用程序。

两年来,除了一个速度更慢、漏洞更多、可维护性更差的网站复制品外,我们什么也没交付。为什么会这样?领导这个项目的人(我–我是这个场景中的混蛋)以前从未在这个网站上工作过。我当时又年轻又笨。

让我们来看一个现代错误的例子:

糟糕的重构:

// 🚩 a single page app for an SEO-focused site is a bad idea
function App() {
  return (
    <Router>
      <Switch>
        <Route path="/product/:id" component={ProductDetails} />
      </Switch>
    </Router>
  );
}

这种方法看似现代简洁,但完全是客户端渲染。对于严重依赖搜索引擎优化的电子商务网站来说,这可能是灾难性的。

好的重构:

// ✅ server render an SEO-focused site
export const getStaticProps: GetStaticProps = async () => {
  const products = await getProducts();
  return { props: { products } };
};

export default function ProductList({ products }) {
  return (
    <div>
      ...
    </div>
  );
}

这种基于 Next.js 的方法提供开箱即用的服务器端渲染,这对搜索引擎优化至关重要。它还能提供更好的用户体验,加快初始页面加载速度,并为连接速度较慢的用户提高性能。Remix 也同样适用于这一目的,在服务器端呈现和搜索引擎优化方面提供了类似的优势。

6. 过度合并代码

我曾经雇佣过一个人,他第一天在我们的后台工作,就立即开始重构代码。我们有一堆 Firebase 函数,其中一些函数的设置与其他函数不同,比如超时和内存分配。

以下是我们最初的设置。

之前:

// 😕 we had this same code 40+ times in the codebase, we could perhaps consolidate
export const quickFunction = functions
  .runWith({ timeoutSeconds: 60, memory: '256MB' })
  .https.onRequest(...);

export const longRunningFunction = functions
  .runWith({ timeoutSeconds: 540, memory: '1GB' })
  .https.onRequest(...);

这个人决定将所有这些函数封装在一个 createApi 函数中。

糟糕的重构:

// 🚩 blindly consolidating settings that should not be
const createApi = (handler: RequestHandler) => {
  return functions
    .runWith({ timeoutSeconds: 300, memory: '512MB' })
    .https.onRequest((req, res) => handler(req, res));
};

export const quickFunction = createApi(handleQuickRequest);
export const longRunningFunction = createApi(handleLongRunningRequest);

这次重构将所有 API 设置为相同的设置,而无法覆盖每个 API。这是一个问题,因为有时我们需要为不同的功能进行不同的设置。

更好的方法是允许 Firebase 选项在每个应用程序接口中传递

好的重构:

// ✅ setting good defaults, but letting anyone override
const createApi = (handler: RequestHandler, options: ApiOptions = {}) => {
  return functions
    .runWith({ timeoutSeconds: 300, memory: '512MB', ...options })
    .https.onRequest((req, res) => handler(req, res));
};

export const quickFunction = createApi(handleQuickRequest, { timeoutSeconds: 60, memory: '256MB' });
export const longRunningFunction = createApi(handleLongRunningRequest, { timeoutSeconds: 540, memory: '1GB' });

这样,我们既能保持抽象的优势,又能保留我们所需的灵活性。在合并或抽象时,请始终考虑您所服务的用例。不要为了 “更简洁 ”的代码而牺牲灵活性。确保你的抽象能够实现原始实现所提供的全部功能。

另外,在开始 “改进 ”代码之前,请认真理解代码。我们在下一次部署一些应用程序接口时就遇到了问题,如果不进行盲目的重构,这些问题是可以避免的。

如何正确重构

值得注意的是,你确实需要重构代码。但要做对。我们的代码并不完美,我们的代码需要清理,但要与代码库保持一致,熟悉代码,对抽象要精挑细选。

以下是一些成功重构的技巧:

  1. 循序渐进: 进行小规模、可控的修改,而不是大刀阔斧的重写。
  2. 在进行重大重构或新抽象之前,要深入理解代码。
  3. 匹配现有代码风格: 一致性是可维护性的关键。
  4. 避免过多的新抽象: 保持简单,除非确实需要复杂。
  5. 避免添加新的库,尤其是编程风格迥异的库,除非团队同意。
  6. 在重构前编写测试,并在重构过程中更新测试。这能确保你保持原有的功能。
  7. 让你的同事遵守这些原则。

 

更好地重构的工具和技术

为确保重构有益无害,请考虑使用以下技术和工具:

检查工具

使用衬垫工具来执行一致的代码风格并捕捉潜在的问题。Prettier 可以帮助自动格式化为一致的风格,而 Eslint 则可以帮助进行更细致的一致性检查,您可以使用自己的插件轻松进行定制。

代码审查

在合并重构代码之前,实施全面的代码审查以获得同行的反馈。这有助于及早发现潜在问题,并确保重构后的代码符合团队标准和期望。

测试

编写并运行测试,确保重构代码不会破坏现有功能。Vitest 是一款快速、可靠、易用的测试运行程序,默认情况下无需任何配置。对于可视化测试,可以考虑使用 Storybook。React Testing Library 是一套用于测试 React 组件的实用工具(还有 Angular更多变体)。

结论

重构是软件开发的必要组成部分,但在进行重构时需要深思熟虑,并尊重现有的代码库和团队动态。重构的目的是改进代码的内部结构,而不改变其外部行为。

请记住,最好的重构通常是最终用户看不到的,但却能让开发人员的工作变得更加轻松。它们在不破坏整体系统的情况下,提高了可读性、可维护性和效率。

下一次,当你有为一段代码制定 “大计划 ”的冲动时,请退后一步。彻底了解它,考虑更改带来的影响,逐步改进,你的团队会因此感谢你的。

你未来的自己(和你的同事)也会感谢你为保持代码库的整洁和可维护性所做的深思熟虑。

本文文字及图片出自 Good Refactoring vs Bad Refactoring

你也许感兴趣的:

共有 150 条讨论

  1. 读完这篇文章后,我意识到自己有点偏离了重构的初衷。

    尤其是 for 循环与 map/filter 的例子–这只是一个微函数,原作者选择哪种可能都没问题。(在一个没有既定风格的代码库中,如果开发人员声称其中一种 “客观上 ”比另一种更好,我会对他产生怀疑)。

    在添加新功能时,如果可以重用其他工作,则在需要时进行重构,重构时尽量少做改动!否则,这看起来更像是你当时的品味问题。

    当然,这也有个限度,但通常是在非常明显的情况下–例如,冗长的函数和参数过多的函数就是明显的候选。即便如此,我也认为只有在添加新功能时才会触及这些问题–这些问题本应在代码审查时就被发现,如果不再触及这些代码,主动重构似乎就是在浪费时间。

    重复代码(过度)合并的例子可能是最吸引我的重构。

    1. 我同意!没有人愿意审查不必要的风格变化。

      > 我同意!没有人愿意审查不必要的风格变化。

      除非有人宣布 “票据破产”,否则可能会有一张 “清理 ”票据烂在某人的积压工作中。

    2. 我同意,for 和 map 孰优孰劣取决于上下文。但 for 更有可能产生副作用。孰优孰劣取决于周围代码的大环境。

      1. > map 通常是功能性的,会分配内存,而 for 不会。但 for 更有可能产生副作用。

        如果/当内存不足时,分配内存本身就会产生副作用…

  2. 除了以下几点,其他都同意:

    >记住,代码库的一致性是关键。如果需要引入一种新模式,请考虑重构整个代码库以使用这种新模式,而不是制造一次性的不一致。

    如果代码库足够大,重构整个代码库的模式往往不太现实(甚至由于 “时间限制”,管理层不允许这样做)。新模式可以应用于范围较大的新功能。这对于几乎从未更改过的旧代码尤其有效。

    1. 关键词是考虑。如果你不会将该模式应用于整个代码库,那么也许你并不想在这一个新的地方引入该模式。

      1. 不是不会应用到整个代码库,而是不会一次性应用到整个代码库。你必须从某个地方开始,而新功能正是开始新模式的好地方。旧代码可以逐段重构。

        1. 在我工作过的大多数地方,没有人能够逐段重构旧代码,也没有人能够完成重构。例外的情况是,有些人记录了工作范围,得到了领导层的认可,然后像其他项目一样持续工作。

          问题是,有时新模式会被更新的模式覆盖,如此反复,直到你有了 2016 年、2019 年、2021 年三个不同的实现,然后你会发现,2024 年,你正在研究第四个实现,而所有完成前三个实现的人都离开了公司,没有写任何文档,也没有完成他们的工作。

          1. 在一个足够大的代码库中,这种情况是不可避免的,你只能把它当作一个事实来接受。如果你有数百万行手写代码,你就会有考古层和一些比其他更现代的区域。这并不好,但 “一切都被锁定在 2003 年建立的模式中,你无法创新 ”是一个更糟糕的问题。

          2. 这就是为什么我总是问 “如果我们半途而废,我们的代码会处于更好还是更糟糕的境地”。如果答案是 “更糟”,那就不要开始。除非你能在一周内完成全部工作。如果需要一个季度的时间,那么调整优先次序的可能性就太大了。

        2. 在我目前工作的地方(一家拥有 200 多名开发人员的企业级 SaaS 公司),我在引入一些抽象/模式的策略上取得了成功。奇怪的是,我们并没有在软件工程中教授或讨论这些内容(我认为)。我看到它们一直在被重新发明。

          借用医学术语: 第一步始终是 “止血”,然后清理伤口,最后保护伤口。

          – 添加弃用标记。在 python 中,这可以是一个装饰器、上下文管理器,甚至是一个神奇的注释字符串。理想情况下,我在首次介绍模式时就会这样做。这样下次搜索时会更容易。

          – 创建一个带逃生口的线程。如果你能进行静态分析、键入提示,那就再好不过了!在 python 中,我会创建 AST、semgrep 或自定义 AST 来捕获这些代码,但会提供一个类似于 `type: noqa` 的神奇字符串来忽略现有代码。然后就有办法跟踪并解决违规的地方了。你可以用它来做一个度量。

          – 系统中的所有东西都必须有一个所有者(个人、小组、团队或部门)。端点有所有者,异步任务有所有者,kafka 消费者可能有所有者,测试用例可能有所有者。因此,如果有任何故障,你可以通过某种方式让这些故障在相应的 SLO 面板中可见。

          最后一步的另一个选择是,“如果可能的话”,一些平台小组可以接手,为其他产品小组进行零成本重构。当然,产品小组必须帮助测试/批准等。如果你为他们做这件事,让他们采用一种模式会更容易。但模式的投资回报率必须要有,而且平台小组有时确实会被困在做一些吃力不讨好的工作。如果你明智地这样做,可能会得到足够的回报,比如更健壮的系统、更好的可观察性/可追踪性、更少的不稳定测试等等。

          1. > 测试用例可能有所有者

            我希望测试用例的所有者和代码的所有者是同一个人。

            1. 说起来容易做起来难 🙂

              测试覆盖的代码可能比不同团队拥有的单个单元还要多,因此最终会有多个所有者。更倾向于将 “团队 ”作为所有者,而不是个人。

              但就像文档一样,所有权也可能会过时和不同步。因此,我们的想法是让 SLO 仪表板中的一些红字随着时间的推移而得到纠正。不可能总是自动将 “测试 ”与 “代码 ”联系起来。

              1. 端到端测试可能会比较棘手。但单元测试应由拥有该单元的人员/团队/小组负责。

                而且,单元测试永远都不应该被破坏/变红。如果需要修改代码,则需要同时修改测试。

                端到端测试可能不稳定。这些测试可能不会妨碍部署,而且可以红一段时间。不过,在忽略这些测试之前,也许应该手动确认测试是否出现了问题、行为是否发生了变化,或者是否有什么东西确实被破坏了。

    2. “愚蠢的一致性是愚蠢的”。-我,浪费你的阅读注意力

      无论选择哪种方法,都需要以适当的周期计算成本/收益,这一点也需要考虑。

    3. 一致性是好的,但不是万能的。如果你有一个非常庞大的代码库,并坚持完美的一致性,那么实际上就无法进行任何更改,所有代码将永远保持 “第一天 ”的风格。

    4. 在我看来,“一致性 ”并不是什么模糊不清的主观因素。如果你想为“$”设定新标准,那就把它变成“$”,为“$”设定新标准。我完全支持这样做,当然要在合理的范围内。当大量的替换操作需要对高度依赖和/或未经测试的代码进行非同小可的重大修改时,我才会回避。否则,如果只是一个简单的任务,一个好的集成开发环境和几个小时的努力就能解决……那就去做吧!

  3. 我写到这里:

    > 如果您需要引入一种新模式,请考虑重构整个代码库以使用这种新模式,而不是制造一次性的不一致。

    撇开 “模式 ”的错误应用不谈(根据四人帮的说法,“模式 ”应该用于特定的设计问题),这种 “重构整个代码库 ”的建议是不切实际的,也是钙化的。

    一致性可以提高可读性,但仅限于一定程度。如果软件要解决的问题发生了变化(成功的软件总是这样),那么软件采用的解决方案也必须随之变化。你可以循序渐进地这样做,尝试可能的新解决方案、实现方式和模式,因为你对所要解决的新问题有了感觉;你也可以坚持 “一致性”,然后发现自己不得不在不现实的压力下进行一次大重写。

    1. > 撇开 “模式 ”的错误应用不谈(根据四人帮的说法,“模式 ”应该用于特定的设计问题)

      这绝不是对 “模式 ”一词的误用。并不存在一个包含所有设计模式的详尽清单。设计模式是指在整个代码库中使用的任何模式,目的是利用现有的概念,而不是每次都发明一个新概念。设计模式不必存在于代码库之外。

      > 一致性会增加可读性,但仅限于一定程度。

      相反:不一致性会降低可读性,而且没有限制。更多的不一致性总是更糟糕,但可以用少量的不一致性换取其他好处。

      就拿你实验新方案的例子来说:在这种情况下,你引入了不一致性,以换取了解新方案是否是一种改进。然而,一旦你了解到这一点,不一致就会变成债务。应该做出决定,要么在所有地方都采用该解决方案,要么退回到最初的解决方案。这正是作者所说的 “考虑重构整个代码库以使用这种新模式 ”的意义所在。

      这种重构或移除并不需要一蹴而就,但需要在投入更多实验之前进行。相反,经常发生的情况是,这些债务从未偿还,代码库中充满了失败的实验,整个代码库变得一团糟。

    2. >“模式”(根据 “四人帮 ”的说法,“模式 ”应该用于特定的设计问题)

      为什么会这样?特别是如果你不是 OOP 的使用者/信仰者的话。模式 “又不是什么晦涩难懂的艺术术语。

      1. 为了保持一致性,同样的设计问题应该有同样的设计解决方案,也就是模式。如果你不重视一致性,就可以随意采取不同的方法。这会让用户感到困惑。我曾经和一个人共事过,读 CSV 这个简单的问题用一个库就解决了。出于兴奋,他又用组合分析器重写了一遍,然后又用宇航员建筑师的功能怪胎重写了一遍,复杂得别人都无法理解。

        这样做是不对的–同样的问题,使用同样的解决方案。我承认这是一个极端的例子,但它也是一个真实的例子,很好地说明了这个问题。

        (另外,模式并不是 OO 所特有的,OO 也与函数式风格不兼容)

          1. > 我是说,“模式 ”不必局限于著名的 “四人帮 ”一书中狭义的含义

            我知道你就是这个意思!对不起,我喝了酒。

    3. 这不仅仅是可读性的问题。开发人员试图重复他们看到的模式。如果你不重构以前的地方,他们就会重复过时的模式。另外,这也会让代码审查变得令人沮丧。

  4. 重构本身并不是目的,也不应该被视为一个独立的项目。

    要实现修复 bug 或添加功能等真正目标,最简单的方法可能就是先重构代码。是的,也许你想把它合并到自己的提交中,因为随着时间的推移会有冲突的风险。但是,仅仅让代码看起来漂亮(对谁来说呢?它甚至会让以后的实际工作变得更加困难。永远不要在实际工作之外进行重构。

    与项目经理的漫画还揭示了一种荒谬的模式:工程师与不懂技术的非技术人员协商何时完成工作的各个部分,而这些非技术人员根本不知道如何完成工作的任何部分。PM 不知道什么是重构,EM 可能也不知道。告诉这些人他们不懂的东西,然后问他们什么时候该做,并不能让组织更好地运转。将其作为实际工作估算的一部分。

  5. 好的重构尊重语言习惯和组织文化。向新方法论的转变是经过深思熟虑的,而且可能会很缓慢,除非发生了革命,但在这种情况下,它仍然会尊重新的文化。

    糟糕的重构是精英主义的,“你不会理解这一点 ”的评论会让所有者拂袖而去,没有人能够理解它。

    这些示例放弃了 FP,而倾向于使用 Java(脚本)的自然习语,这恰恰说明了这一原则。我可以想象一家银行的量子车间从其他东西重构为纯粹的 Haskell,并对其尊重 FP 而感到完全满意。

    因此,表面上的 “FP 模式不好 ”有点轻描淡写。问题的关键在于,在那个特定的团队中,没有人会真正去维护它们,除非它们是文化的一部分。

    另一个例子是 “如果你像达夫的设备那样展开循环,你应该解释一下为什么要这么做”。

    1. 对 FP 的指责很奇怪,因为 “好的重构 ”也使用 FP,只是在 JS 中内置了 FP。我同意这更好,但主要是因为它是内置的,而且是习以为常的,所以功能上还是完全一样的。

      1. “我不喜欢这种 FP 编码风格”,好吧:如果你负责管理团队并拥有代码库,你就可以强制执行。因此,好的重构就是风格指南的执行。

        我想我过度解读了他对 FP 的厌恶。实际上,他的抱怨是 “为什么要引入新的依赖关系”,作为抱怨,我完全可以接受。这并不酷。

        他的很多例子都有点掩盖了重点。如果他想先写一个抽象,我想 “不要编写 FP 代码 ”就不会出现在里面了。而 “使用.filter 和.map 等语言中的方法 ”可能会出现。

      2. 有道理。即使当时提到了使用 filter 和 map。但糟糕的重构也使用了 filter 和 map。这完全是编程范式的改变。

        从文本内容来看,我本以为会使用基于范围的 for 循环进行一些小的重构(这是个东西吗? 我的语言很生疏)。在不改变编程模式的情况下,你可以获得 map 的优势(没有逐一索引错误)。

        1. 你甚至不需要范围循环。

          有了 forEach:

           list.forEach((item, index) => doSomething());
          

          您还可以使用 for/of,但如果需要的话,它没有索引

           for (const item of list) { doSomething() };
          

          这只是在最常见的情况下省去了索引递增的需要,在这种情况下,索引递增一个,直到到达列表的末尾。

      3. 我想这就是问题的关键所在–库没有增加任何东西,纯粹的 javascript 也能实现同样的效果。

        1. 如果这才是重点,那么这一段就需要重写了。

          1. 事实上,这一段需要好好重构一下

    2. >好的重构尊重语言的习惯用法和组织的文化。向新方法论的转变是深思熟虑的,而且可能会很缓慢,除非发生了革命,但那时,它仍然是对新文化的尊重。

      伯克式编程

  6. 第一个示例抱怨了重构对函数式思想者的吸引力(暗示现有开发人员很难理解),但随后的 “改进 ”版本除了在第一个示例中使用了 Ramda(不必要?

    虽然很多开发人员都不愿意尝试功能性方法,但第一个示例比原始代码读起来要好得多,我无法相信有些人更喜欢命令式循环/条件嵌套方法。

    1. (举手)我更喜欢 for 循环。将项目推送到数组是创建数组的惯用 Javascript 方法。if 语句是一种有条件的惯用方法。这也更容易调试。

      map 和 filter 方法也不错,但它们只适用于单线程。

      1. 在 Fortran 和人类可读语言出现之前,编写汇编程序是编程的惯用方式。

        在Algol和结构化编程出现之前,使用goto编写程序是惯用的编程方式。

        在结构化数据类型(以及后来的对象)出现之前,只有少数标量类型是惯用的编程方式。

        在模块系统出现之前,将程序写成文本片段,在构建时以某种方式粘合在一起是惯用的方式。(虽然 C 和部分 C++ 仍然活在 20 世纪 70 年代)。

        在未来/承诺和适当的语言支持出现之前,回调地狱(Callback hell)是实现异步的惯用方式。

        有时,是时候继续前进了。对某些人来说,编写习语化的 ES5 可能会感觉有趣,但这可能不是达到高生产率和结果正确性的最佳方式。

        1. 这样的类比并不能证明什么,只是一种暗示。我从中得到的结论是,你认为 for 循环是过时的。

          1. 就是因为它们过时了。函数式代码更具可读性。回顾过去,编程语言的所有进步基本上都是为了 “让代码更易读”。因此,for 循环(就这种用法而言)已经 “过时 ”了。

            1. > 函数式代码更具可读性。

              不可能

               name: R.pipe(R.prop('name'), R.toUpper),
                  age: R.prop('age'),
                  isAdult: R.always(true)
              

               name: user.name.toUpperCase()、
                  age: user.age、
                  isAdult: true
              1. 当然是这样!第一个例子毫无必要。

                但是

                 for (const i=0; i < data.length; i++) {
                    new_data[i] = old_data[i].toUpperCase();
                  }
                

                您可以编写

                 const new_data = old_data.map((x) => x.toUpperCase());
                

                我认为这样更清晰,也更不容易出错。

                1. 第二段代码无法编译,并且引入了新的依赖关系。

                    1. 对不起,我以为是 C++。我应该学习 typescript,这是我推迟学习的原因之一。

                    2. 该代码段中没有任何内容是专门针对 Typescript 的,它只是普通的 Javascript。

                      所有语法上有效的 Javascript 也是语法上有效的 Typescript,它只是添加了一些东西,不过你可能会因为一些事情而在运行时出错,比如以 Javascript 可以接受而 Typescript 不允许的方式重新分配变量。

              2. 但这些都是功能性的,或者说很容易做到。

            2. 你说它更具可读性,我不同意!

              这只是时尚吗?除了 “我更喜欢 ”之外,还有其他解决方法吗?

              1. > 这只是时尚吗?

                是的。

                可读性是读者的特点,而不是阅读的内容。

                这个简单的道理对很多人来说似乎很难理解。我的理论是,大多数程序员从未接触过截然不同的陌生语言和编程风格。如果他们被迫面对并内化 2-3 种不同的代码编写方式,他们就会意识到这一真理。

                就我个人而言,我曾经认为 Lisp 是不可读的……直到我学会了它。我曾经认为 BASH 不可读……直到我学会了它。其他半打语言也是如此。风格也一样。“可读性 “只是读者的熟悉程度和熟练程度。

                1. 30 多年前,编程圈子里有一个普遍的说法:C 代码是不可读的,除非是对作者而言,或许甚至对作者而言也是如此。

            3. > 功能代码更具可读性。

              我只知道你喜欢函数式代码。

      2. 我想说的是,在 2024 年,我觉得 for/of 或 forEach 至少可以让你避免索引的模板化。

      3. 我不经常用 JS 编写代码,所以这就是问题所在。

        两组代码都很好,但循环变体我一眼就看懂了,而 FP 代码则花了我一些时间。

        顺便提一下:我唯一真正用 JS 编写的代码是优化一些性能关键代码,我确实不得不将一些 FP 链重构为循环。这是因为 FP 方式每一步都要构建一个新列表,速度很慢。

      4. 我也更喜欢循环,我会否定条件并使用 continue,除此之外保持不变。我对函数式版本没有意见,但它的扫描效果不佳。

    2. 撇开美学不谈,我的印象是,人们在开始编程时,基本上都是使用命令式的 for/if => 风格,因此命令式风格更容易被更多人阅读。即使对于更有经验的程序员来说,阅读命令式也可能花费更少的精力,因为它更内在化?

      此外,在 JS 中,函数式的性能较低(在我的机器上几乎是命令式的两倍,我想这是因为它减少了无用的内存分配)。

      那么,功能相同、可被更多人阅读、性能更高?命令式示例似乎是更好的代码。

      1. > 即使对于经验丰富的程序员来说,阅读命令式语言也可能花费更少的精力,因为它更内在化?

        我不同意。For 循环通常更难推理,因为它们更通用、更强大。如果我看到 “for(……)”,我只知道后面的代码会迭代,但实际含义必须从内容中推断出来。

        与此同时,.map()或.filter()已经给了我提示–lambda 将转换值(map),将过滤值(filter),这些提示使理解逻辑变得更容易,因为你已经理解了 lambda 要做什么。

        其他好处来自于这些构造的惯用用法。将不同的事情混合到一个 for 循环中是很正常的,例如,过滤、转换、添加到生成的集合都在同一个代码块中。在函数式方法中,处理过程的不同 “阶段 ”被隔离成更小的块,更容易进行推理。

        另外,不可变的数据结构在函数式编程中是非常自然的,在考虑程序状态时,它们是一个重大简化。一个给定的变量只有一个不可变的状态(在当前执行中),而不是在 for 循环过程中被改变 1000 次。

        1. > 如果我看到 “for(……)”,我只知道后面的代码会遍历

          然后就有人在宏中加上 do {} while(0) 。

        2. 无需担心可变的本地状态。共享状态才是不可变数据结构的真正亮点。

      2. 我是 Datapoint 分数为 1 的人,我发现用函数式风格来表达正在发生的事情的想法要容易得多。尤其是在引入中间变量和转换构造函数,而不是依赖完整链条的情况下。例如

         function processUsers(users: User[]): FormattedUser[] {
              let adults = users.filter(user => user.age >= 18);
              return adults.map(user => FormattedUser.new_adult(user));
            }
        

        关于性能提示,我们讨论的是什么规模?它与目标系统相关吗?很明显,这个示例是合成的,所以我们无法得知,但在某种合理的用例中,这样的运行时性能是否有意义?

      3. > 我的印象是,人们在开始编程时,基本上都使用命令式的 for/if 风格 => 因此命令式风格更容易被更多人阅读。

        在我看来,这是技术发展快于社会发展的简单结果。现在仍有一些讲师在这样的环境中学习编程:命令式编程的首选是 C 和 FORTRAN;其他范式(如果你听说过其他范式的话)的首选是 Lisp、Haskell 和 Smalltalk;CPU 的速度以 MHz 为单位,而你必须与其他人共享这些机器。当然,你会在命令式编程方面获得更多经验;而且熟能生巧。

        但实际上,我坚信函数式风格–适当的因子–要直观得多。将某个输出集合初始化为默认状态(或许还能意识到零并不是特例)、跟踪输入集合中的某个位置以及重复追加到输出中,这些机制并不那么有趣。当然,对于全新的程序员来说,想出这些步骤可能是一个有用的解决问题的练习。但是,还有无数种其他选择–而且,无论如何,解决问题都是非常难教的。最终的结果是,你以为你已经教会了一种技能,但实际上学生已经记住了一种模式,并且会一味地试图在今后的学习中尽可能多地应用这种模式。

        > 此外,在 JS 中,函数式的性能较低(在我的机器上几乎是两倍,我想这是因为它减少了无用的内存分配)。

        当然:

            $ python -m timeit "x = []" "for i in 'example sequence':" "  x.append(i)"
            500000 loops, best of 5: 796 nsec per loop
            $ python -m timeit "x = [i for i in 'example sequence']"
            500000 loops, best of 5: 529 nsec per loop
        

        … 当然

            $ python -m timeit "x = list('example sequence')"
            2000000 loops, best of 5: 198 nsec per loop
        

        马到成功。

        1. 对于许多纯粹的数据转换任务而言,函数式编程更为直观。

          但对于整个系统来说,它并不更直观。

          当你在文件系统中创建一个新文件时,你已经违反了函数式编程,即使你原子式地创建了该文件,并指定了所有内容,并使其不可变。

          你必须构建一个新的文件系统,它与旧的文件系统类似,但带有该文件,然后让整个系统尾呼进入一个你将新文件系统作为参数传递的世界,这样旧的文件系统就不为人所知了(垃圾)。

          在让普通人掌握部分函数式编程方面,Unix 是最成功的系统。

          像 < 源文件 | 命令 | 命令 |… | 命令 > dest-file 这样的 Unix 流水线是函数式的,除了 dest-file 被掐断的部分。或者至少可以是功能性的。命令的参数可以是命令式程序(如 awk),但效果是包含在内的。

          在道格-麦克罗伊(Doug McIlroy)和克努特(Knuth)解决一个问题的著名对决中,麦克罗伊结合几个工具写了一个简洁的 Unix 脚本,麦克罗伊的解决方案可以被认定为功能性的:

            tr -cs A-Za-z 'n' |
            tr A-Z a-z |
            sort |
            uniq -c |
            sort -rn |
            sed ${1}q
          

          没有任何 goto 语句或赋值。

        2. 函数式编程给我带来的一个问题是,我发现它很难调试。

          在命令式编程中,你可以一步一步、一行一行地跟踪程序,可以用调试器、纸笔或在头脑中完成。

          而函数式编程则不然。它运行的是函数。这些函数是做什么的?不知道,它们是从代码的其他地方拼凑起来的。而且由于懒惰的评估,这些函数可能根本就不存在。在设计阶段,这主要是件好事,因为它很灵活,而且纯函数比带副作用的函数更不容易出错,但总有一天,无论你采用什么范式,程序都会出现问题。这就是问题所在。

          如果滥用抽象,这也是对象编程的一个问题,事实上,这也是抽象的一个普遍问题,只是函数式编程将其默认为抽象,而命令式编程默认为具体。

          至于 Python 的例子,我有点惊讶优化器竟然没有发现这个问题,因为这三个结构都是常见的等价结构,本可以用性能最好的实现来代替,大概就是第三个。不过,优化器还是很复杂的。

        3. 不过,人们的思维方式是强制性的。如果我想去拜访我的朋友,然后在回来的路上去加油,那么我想象的步骤就不是功能性的。

          1. 我不同意。在与计算机打交道时,人们有时不得不采用命令式思维,但我在设计程序时通常采用声明式思维。

            比方说,如果我想过滤一个用户序列,省略 18 岁以下的用户,我会构建我的谓词(一个 “什么”),然后想应用这个谓词创建一个新的用户序列(另一个 “什么”)。

            我真的不想每次都告诉计算机如何处理一个列表。我并不在乎先创建一个索引,然后检查列表的长度,以便跟踪我是否按顺序处理了列表中的每个用户,同时不要忘记那个重要的 “i++”。此时此刻,我只想用流来思考问题,而这种流处理也可以并行地进行,这是我所关心的。

            但我也确实认为 Python、Haskell 等语言在列表理解方面最具表现力。在我看来,没有比这更简洁的了:

              users_adult = [
                user
                for user in users
                if user.age >= 18
              ]
          2. 这一点很值得商榷。

            在这种情况下,你首先要宣布最终目标–拜访朋友并加满油,而实现目标的实际步骤则不那么重要,往往要留待以后再确定(例如,哪个加油站、哪个加油泵等)。这更符合功能性思维。

            命令式思维则更多地对应于 “我将坐在车里,启动发动机,在高速公路上行驶,在 X 地址停车,与 Y 交谈,2 小时后离开,在 X 加油站停车”–在这种情况下,命令式步骤是主要模式,而实际意图(拜访朋友)只是隐含的。

            1. 你的第二部分是我的想法,也是我认为大多数人的想法。这正是我的意思。

              1. 所以,当你提前两周安排好与朋友的会面时,你首先想到的是坐在车里、驶出车库、上高速、打开收音机、停车、按门铃等无数个动作,而与朋友的实际交谈只是其中的一个动作,与其他动作相比并不突出?

                我当然不会这么想。我的主要目的是拜访朋友。交通是次要的,它只是实现目标的一个手段,一个我不太在意的实施细节。如果我想去,而且去的那天天气不错,我甚至可以不开车,而是坐火车,甚至骑自行车。

                现在反思一下,我认为这种专注于过程(而不是专注于目标)、精确的命令式顺序、无法改变计划(即使这种改变对目标来说毫无意义)是自闭症的一种表现。但我不相信大多数人都是这样想的。

                1. 不,我们只会想:“我要上车,开车去医院,和我生病的朋友谈谈,然后开车从加油站回家”。这是更高层次的,但肯定还是程序性/命令性的。(我们在开车时会想 “我要在这里左转 ”或 “我最好超过那辆货车”,或者其他什么的。但我们不需要事先计划好这一切,而是在行驶中逐步完善)。

                  我认为大多数人或多或少都会这样思考,这并不是 “自闭症的表现”。

                2. 是你添加了所有这些关于精确性的条件,关于需要重放每一步的条件(即使这也是个问题,因为步骤是分形的),或者关于不能改变计划的条件。我可以迫切地思考,也可以做到这些:)

          3. 我对大脑了解不多,但我知道研究大脑建模的人曾使用过一些模型,在这些模型中,有两种类型的思考方式:陈述性思考方式和程序性思考方式。参见 SOAR 和 ACT-R。

            这里需要注意的是,计算机在多大程度上影响了他们的大脑模型,但他们都是专业的认知研究人员,所以我会对他们持怀疑态度:)

          4. 虽然我思考问题有明确的顺序,但我也会成套思考。如果我抓了一堆花生,我不会想象逐一抓起每一颗花生,而是想象同时抓起一堆花生。

            1. 如果你抓了一堆花生,你可能会用 “满手 ”来思考,但世界和我们采取的行动在我们分析它们的方式上是分形的这一事实并不能证明二者之一。

    3. > 我无法相信有些人更喜欢命令式循环/条件嵌套方法。

      对,这就是你的问题所在。事实上这是可能的!

    4. 我很久没有写过 JS 了–像 V8 这样的引擎是否足够聪明,能将过滤器和映射卷入一个循环?否则,reduce 不是更有效率吗?

      1. 这不是够不够聪明的问题。由于 JavaScript 是解释型的,因此优化发生在运行时。如果代码只执行一次,且数组中的项数较少,那么编译器优化代码所需的时间将比天真地执行代码更长。大多数代码都属于这种情况。

        至于是否有可能将映射和过滤合并到一个循环中,我想这取决于第一个操作是否会产生影响第二个操作或正在迭代的集合的副作用。我不知道答案是什么,但如果没有一些难以察觉的角落情况禁止这种优化,我会感到很惊讶。

    5. 你是说你很难相信有些人更喜欢第一个 “Before ”的例子,而不是第一个 “Bad refactor ”的例子?

  7. 好的重构应该大大减少代码库的大小或复杂性。

    这两个指标是相互关联的,但一般来说,如果代码库的压缩包大小(忽略注释)没有减少,那么这可能不是一次好的重构。

    1. 我不同意,看看其他人怎么说。

      我认为大小的减少没有任何意义。我承认我自己的折射仪倾向于把东西变小,但这只是一种倾向。有些折射仪肯定会增大整体尺寸。我目前正在重构一个代码库–以前每个项目都有一个类。每个对象在创建后都要经过检查,然后设置一个运行时标志: 拒绝或接受。随着代码团队的壮大,我发现我在 “接受/拒绝 ”的问题上浪费了大量时间。现在我进行了重构,为每个项目设置了两个类,一个用于 “接受”,另一个用于 “拒绝”。模板的数量肯定会增加代码的体积,但这是值得的。

      至于复杂性,我不知道。

      我重构代码的唯一目的是为了让人理解。这是最终目标。还能有什么其他目标呢?

      1. 当然,可理解性是最终目标,但代码量的减少与此相关,因为它是一个易于测量、可预测的代用指标。不,将 3 行代码减少到 1 行并没有什么帮助,但更大的大小差异总是 “XYZ 上的组合 ”类型的,它改变了你需要编写多少代码的整个复杂度等级(比如 React 让人们编写 O(states)渲染代码,而不是 O(transition))。

        1. 大小通常会随之减少,但不一定。容易测量与此无关。说它是一个具有很好预测性的代理,我现在还不太相信,你能试着证明一下吗?一些非常简洁的代码如果没有(全面的)注释可能很难理解,例如 https://en.wikipedia.org/wiki/Fast_inverse_square_root#Overv

          也许你是对的。我喝了点酒,明天早上再考虑,谢谢。

          1. 如果你不在乎性能,我们可以把代码缩短很多。

              float Q_rsqrt(float number) {
                return 1 / sqrt(number);
              }
            

            快速平方根倒数 “绝对 100%地与性能有关。如果要将其作为反例,您需要展示仍然符合约定(约定是:与该代码一样快)、更长、更清晰的替代代码。

            1. 我的意思是,更短的代码不一定更易读。但你说的是优化,我没意见,用更丑的代码换取更高的速度(或其他)。但这是重构吗?我不这么认为。我可能把你带入了一个误区。

      2. 其他目标可以是性能、可测试性

        1. 可测试性是一个很好的目标,谢谢。关于性能,我不是很确定,我会再仔细斟酌一下。

          1. 想象一下,您重构了代码,降低了复杂性,但同时也降低了性能(缓存的例子)–您会继续重构吗?

            从我的角度来看,重构后的系统更容易理解,但也更加耦合。这样可以吗?如果不行,是否可以现在合并重构的内容,然后在单独的重构中处理重构的结果。缓存重构也可以获得支持–即移除缓存,因为给定请求不应该被缓存,或者该功能应该解耦并在其他地方完成。

  8. 哦,上帝啊,“面向对象 ”重构。我希望每个在 2000 年代初被灌输了面向对象思想的人都能得到一些明确的信息,让他们知道他们所接受的教育本质上是一个骗局,与 Alan Kay 的初衷毫无相似之处。

    1. 这其中有一部分是艾伦-凯的责任,因为他花了二十年时间才意识到人们无法理解他对 “面向对象 ”的正确定义。

    2. 公平地说,今天的 OOP 与 Simula 比起 Small Talk 要相似得多,在阅读维基百科时,我几乎可以看到 1:1 的映射,包括建模哲学和所有这些,人们大多是从 Alan Kay 那里借来这个名字的。

    3. “Object“(对象)和 ”orient”(方向)都是普通的英文单词,Alan Kay 并不拥有这两个词。他可能是第一个将这两个词合并为一个词的人,但他也不拥有这个词。(与他的定义不同的另一个定义成为主流定义,这只是事实,绝不是 “骗局”。

  9. 这是一个有趣的话题,但我不认为这篇文章有效地传达了信息。

    该书的标题侧重于好的重构和坏的重构,但大部分内容讨论的是好的设计和坏的设计。这意味着,许多糟糕的示例本身就是糟糕的,无论它们是从另一个版本重构而来还是从头开始编写。介绍性漫画和结论中提到了如何进行重构,但文章的其余部分却偏离了这一点,只讨论了重构后的代码。第一个陷阱提到了改变编码风格,但解释实际上是针对引入外部依赖性的问题。第五点 “了解业务背景 ”实际上应该是 “不了解业务背景”。如果我们以增量的方式进行重构,那么在重构过程中难免会出现一些不一致的地方。因此,第三个陷阱 “增加不一致性 ”应该包括额外的解释。

    总之,我认为这篇文章如果能更多地关注如何进行重构,而不是批评某一段代码,会更有帮助。

  10. 我同意这篇文章的观点,最近我认为最好的重构就是不进行重构。希望达到一致性是一个很高的标准。

    通常情况下,代码结构的重要性远不如数据流和数据管道。因为大多数代码库都混合使用了多种语言:

    – 全局状态或单子,–外部提供的配置(配置文件、环境变量、cmd 选项、功能标志等),然后被分解并传递,–封装器和垫片,–推拉混合以获得函数的输入/输出,–处理可变状态的假设没有一致性或代码表示、

    使用此处列出的想法来构建现有代码,可能比尝试重构代码以改善其结构更好:- 开放/封闭原则 = 为新功能编写新代码(而不是修改),- 构建松散耦合的模块(通过简单类型和一致的传递方式实现接口),- 通过 CI 强化导入顺序依赖关系(在无关功能添加了一些不 “属于 ”的导入后,几个月后就不会出现令人惊讶的循环依赖关系了)

    如果数据流(输入、输出、状态和配置)在代码库中的流向一致,代码的结构就会变得简单。

  11. 无法用语言表达我对这类文章的厌恶。

    试想一下,在一个遗留代码库中工作,项目经理坚持重构是坏事的教条,希望你做错,甚至对你的 PR 进行微观管理。

    我经常看到,由于缺乏内部讨论最佳实践的机会,没有测试潜在解决方案的空间/时间,以及领导开发人员很像独裁者,导致项目受挫,程序员辞职。

    让我猜猜,这篇文章是某个项目经理写的,他们只想让你尽快推出产品,施加压力,不允许你重构。这只是软件开发中的普通一天。当大多数网络应用程序多年来一直存在愚蠢的 bug 时,我已经不再感到惊讶了,因为这将会成为一个 Jira 票据和一个关于重构的大讨论…..。

    几年前,我在大约 3 个月内重写了一个完整的 SaaS,而另一个团队的 5 名开发人员花了 12 个月。猜猜哪个版本让投资者满意,是我的版本。

    糟糕的重构只是不良工程文化的产物。

    1. > 想象一下,在一个遗留代码库中工作,项目经理抱着 “重构是件坏事 ”的教条,希望你做错,甚至对你的 PR 进行微观管理。

      我不认为这篇文章说了什么?文章只是列举了一些重构时可能出错的常见问题,并举了一些例子。

    2. > 让我猜猜,这篇文章是某个 PM 写的吧

      不,从文章的辅助内容(域名、其他文章的链接、广告等)来看,写这篇文章的是某个销售某种 “人工智能 ”代码工具的人。

      (可能是内置了 Magikal 重构功能的工具… 有价无市)。

    3. 更多的人意味着要花更多的时间去协调,而在有限的时间里,你会把所有的时间都花在聊天上,而没有时间去编码。

  12. 为什么要雇一个新人,然后让他进行重构?然后写一篇毫无用处的文章,好像这是什么开创性的见解。

    重构被高估了,再加上重构的意义在于:明确术语,然后做事情

  13. 我倾向于认为,衡量各种重构的一个好标准是:”代码少了吗?

    我发现几乎总是少就是最好的。

  14. 在移除缓存的示例中,有一个很好的重构机会。

    也就是说,将缓存逻辑从 API 调用逻辑中提取出来。

    缓存本可以是一个封装 API 调用函数的更通用的函数。这样,每个函数只做一件事,而缓存部分可以在其他地方重复使用。

    相反,有人给出了这样奇怪的建议:改变行为就是糟糕的重构。这太奇怪了,因为我们根本不把这叫做重构。

    编辑:删除了不必要的否定。

    1. 开发人员经常修改代码以改变行为,并坚持认为这是重构。

  15. 又一个人工智能工具的广告贴。

    重构是移动现有代码,而不是引入新代码。用 cacheManager 代替 localStorage 方法就是一种修复/功能。更新代码库的一部分,使其工作方式与其他部分完全不同,也是一种修复/功能。将 processUsers 改成一个完全无用的类不属于重构,而属于修复/功能。自 2018 年以来,对于一个注重搜索引擎优化的网站来说,单页面应用程序并不是一个坏主意。文章中大多数 “重构 ”的例子都是实际的修复和功能,它们给软件带来了(坏的)或没有带来(好的)新的回归。

    1. > 又一款人工智能工具的广告贴。

      人工智能工具的广告贴似乎(几乎?)总是由人工智能工具撰写的。

      只是我不知道其中有多少是由计算机化的人工智能工具撰写的。

  16. 我从这件事中得到的启发是,即使是那些把信任和速度放在首位、避免使用预提交代码审查的团队,也有充分的理由在头几个月把所有新员工都列入需要批准的名单中!

  17. 我不确定是否同意这篇文章,但我同意并非每次代码都需要格式化。

    > 他们当中有很多人都坚信我们的代码需要重构。

    代码可能是这样,但许多开发人员的一个盲点是,他们不熟悉代码并不意味着代码不好。多年来,我所见过的很多重构论点都可以归结为 “我就是不喜欢这套代码”,而且这些论点通常都是在某人刚刚加入团队,还没有时间真正熟悉代码的情况下提出的。

    文章的第一点提到了这一点,但我认为主要是没有抓住重点。在我工作过的几个团队中,我们有一条基本规则,即在加入团队的几个月内(3 个月或以上),不允许提出大量的重构建议。更具体地说,你可以谈论它,也可以进行头脑风暴,但它不会被列入backlog或在冲刺阶段被考虑。之后,任何提议都会得到认真考虑。这涉及到各种不同类型的应用程序、不同的语言和不同结构的代码。事实证明,大多数情况下,如果他们已经提出了重构建议,也会比他们最初的想法缩减很多。原因很简单,因为他们已经使用过这些代码,更好地理解了代码结构的原因,并对代码有了更深入的了解。更重要的是,有一次还有人提议对某个代码库进行更广泛的重构,但这一次的重构更符合特定的情况和环境,否则就不会有这样的重构了。

    编辑:看来我忽略的第四点已经提到了这一点。如果是我,就会从这一点入手,而不是列出这个片段示例清单。

  18. OO 重构并不是真正的 OO。它的蛛丝马迹在于,它是以 “做什么 ”而不是 “是什么”(动词与名词)来命名的,而且名称中还以 “或 ”结尾[0]。这只是一个伪装成类的函数。

    为了引入 OO 概念,更好的重构应该是在用户类上引入一个 isAdult 函数,或许还有一个格式化函数。这样再加上功能性重构,可能会是最好的代码。

        return users.filter(u => u.isAdult())
          .map(u => format(u)); // maybe u.formatted()
    

    [0] https://www.yegor256.com/2015/03/09/objects-end-with-er.html

    1. > u.isAdult()

      是否成人不是用户的属性,而是用户所在的司法管辖区的属性。在某些地方或某些情况下是 18 岁,但在其他情况下可能是 21 岁。

      如果你的软件不打算只在美国运行,那么在用户中实现 isAdult 并不是一个好主意,而是在一个包含目的和地点数据的独立实体中实现 isAdult。

      1. 如果采用适当的 OO,您仍然可以在用户对象中实现该功能。

            boolean isAdult() {
                return this.age >= this.location.ageOfAdulthood();
                // or this.location.isAdult(this.age);  pick your poison!
            }
        

        ……总之,这只是一个如何引入 OO 概念的例子。编程中的一切都取决于

        1. 在这里,我只是想开个玩笑: 是的,这可能行得通,但在一个大型/国际化的系统中,理想的责任应该在别处,因为: * 你可能需要确定一个人是否成年:

          * 你可能需要在与当事人目前居住地不同的司法管辖区确定其是否成年。他们的公民身份可能在其他地方,或者你运行的报告可能希望 “成年 ”是按照其他地区的标准,等等。

          * 有时所需的基本成人需求略有不同,比如饮酒或投票。

          * 可能会有一些奇怪的国家或省份的法律需要额外的因素,比如一些奇怪的地方,男女的年龄界限是不同的。

          1. 是的,在这一点上,这只是极端的自行车舍弃。但是,只要有更多的 OO 原则,比如接口,这些都不是不可能的:

                class User {
                    // Convenience function to check if the user is an adult in their current location
                    boolean isAdult() {
                        return this.location.isAdult(this);
                    }
                    boolean isOfDrinkingAge() {
                        return this.location.isOfDrinkingAge(this);
                    }
                }
                interface Location {
                    boolean isAdult(User u);
                    boolean isOfDrinkingAge(User u);
                }
                class WeirdLawsLocation implements Location {
                    boolean isAdult(User u) {
                        return switch (u.gender()) {
                            case MALE -> u.age() >= 16;
                            case FEMALE -> u.age() >= 18;
                        }     
                    }
                    boolean isOfDrinkingAge(User u) {
                        return u.age() >= 21
                    } 
                }
            

            假设您要检查用户当前不在的地方:

                class SwedenLocation implements Location {
                    boolean isAdult(User u) {
                        return u.age() >= 18;
                    }
                    boolean isOfDrinkinAge(User u) {
                        return u.age() >= 18;
                    }
                }
                var sweden = new SwedenLocation();
                sweden.isOfDrinkingAge(user);
            1. 这感觉就像是提供了一个不应该出现在用户界面上的方法,却提供了不必要的间接层次。

                  j = Jurisdiction.fromUserLocation(user);
                  j.isOfDrinkingAge(user);
              1. > 无论如何都不应该出现在用户界面上。

                这只是你的观点。提供方便的函数是可以的。我认为我们的实现中的间接性没有区别,只是我的实现更自然,你不必知道如何获取位置或管辖区来回答这个问题: “这个用户是成年人吗?知道它使用的是位置或辖区是一个实现细节,你不应该把自己和它联系在一起。

                干杯,伙计,我想我已经完成了这次对话的目标移动:)

            2. 顺便说一句,这次讨论让我真正意识到简洁的方法体在 Java 中是多么有用:https://openjdk.org/jeps/8209434

              1. 那我挑战你做得更好。

                同样来自指南:

                > 请不要发表肤浅的评论,尤其是对他人作品的评论。好的批评性评论能让我们学到一些东西。

      2. > 成人不是用户的属性,而是用户所在辖区的属性。

        这是两者的共同作用。我认为这主要是人的作用: 与司法管辖区无关,“成人 ”的含义至少在全球范围内有一个大致的共识,而且大多数司法管辖区都设定了相当类似(其中许多是相同的)的限制。

        一个四岁的孩子在任何地方都不是成年人,而一个四十岁的孩子在任何地方都是成年人。

    2. 如果有一个纯函数可以把任何有年龄的东西作为输入呢? 显然,这对猫来说是行不通的,但这只是一个例子。 这需要排版脚本,我不知道该如何命名文件,但我认为考虑这种鸭子排版风格很有趣。

          function isAdult({age}: {age: int}) {
              return age >= 18
          }
      

      ps: 我用 function 代替 const 是因为我不喜欢集成开发环境说我不能在定义之前使用某个函数。在函数定义之前就能使用它,这不是一个错误,而是 javascript 的早期特性。将调用者置于被调用者之上更便于阅读代码。

      1. 这不是面向对象。在面向对象中,你倾向于询问对象自身的情况,Yegor 在他的《优雅的对象》一书中谈及了这一点。

        你提出的只是函数或面向数据的编程;如果这是你的兴趣所在,那也没问题,但我会因为你上面概述的原因而保持警惕。一本书能成为成人吗?电视节目呢?isAdult实际上只适用于用户,而且真正属于那个对象。

  19. 我讨厌这篇文章。这是一种非常自以为是的指责开发人员的方式,他们只是想让应用程序变得更好,而问题可能出在文化上。糟糕的重构通常是因为进行重构的人在重构过程中遭到了大量的反击–他们可能低估了重构所需要付出的努力,并且因为重构时间过长而遭到斥责,因此他们偷工减料,导致功能意外丢失,或者他们没有完成预期的抽象/简洁代码,导致代码的可读性降低。

    对于新招聘的开发人员来说,重构代码也是让他们感受到自己对代码所有权的一种方式。项目经理应该很高兴,因为他们在思考代码的工作方式和代码应该如何工作。公司有责任制定审查和质量保证流程,以便在问题导致停机之前将其解决。

    我并不反对某些例子是糟糕的重构,但就增加不一致性而言,我认为这种情况更多发生在匆忙推出新功能或错误修复时,而不是重构时;通常重构是试图建立某种一致性。示例 5 并不是重构,它只是删除了功能。如果这就是重构的目的,那么就应该告诉这个人不要这么做。如果这是更大范围重构工作的意外副作用,那就在新的 PR 中重新添加功能。接受错误的发生,采用一些 QA 控制措施来发现错误,并建立一种鼓励开发人员关心产品的文化。

  20. 老实说,我觉得这篇文章很虚伪。其中提到的 “重构的常见陷阱 ”之一是 “重构前不理解代码”。是吗?这同样适用于对代码做任何事情。下面一条是 “理解业务背景”(注意,作者已经脱离了罗列 “常见陷阱 ”的模式,而是随心所欲地写作。或者说,他刚刚发表了自己的初稿)。

    这不是一篇很有质量的文章。

  21. 这段代码之前和之后都是无法阅读的混乱。

    这篇文章提醒了我为什么如此痛恨 JavaScript。我知道你们前端工程师无法避免,但我希望我们能想出更好的办法。

    1. 你喜欢用什么语言打发时间?

      1. Golang 是我现在最喜欢的语言,但我认为可以用很多语言创建可读代码,即使是我不太喜欢的语言。JavaScript 及其同类语言给我的印象是最糟糕的 “现代 ”语言。再次重申,这里没有仇恨。20 年前,我就开始学习 JS 和 PHP。每次看到现代的 JS 语法,我都会感到恶心,真希望它能简单一些,这样我就能重新开始使用它了。

  22. 简而言之:好的重构=在不影响功能的前提下提高可维护性、简洁性和可用性。

    从根本上说,就是在不破坏功能的前提下让它变得更简单,或者实际上让它变得不简单。

  23. 显然,真正好的重构是使用 reduce 而不是 filter / map。

    不需要在数组上循环两次。如果有链式方法穿过数组,一定要使用 reduce

  24. 关于第一个例子:不管是谁认为以字符串形式传递属性名比任何其他方式都要好,都应该吊销他的编码执照!

  25. 好的重构不会改变行为,我希望作者从这一点出发。同时采取更多更小的步骤。在最初的 6 到 9 个月内不接触一段代码是我不太赞同的。通过提取变量和方法来分解复杂的方法确实有助于学习代码,同时又不会破坏代码。如果你担心一致性,那就结对练习合奏编程,而不是异步代码审查。让新开发人员独自处理代码,并在他们完成所有工作后反馈他们做错的地方,这不是对待团队成员的好方法。

    1. 重构不会改变行为。顾名思义。

      如果有什么改变,那就不是重构。它是一种改变。

      就像例子中移除缓存一样: 不是重构。

      改变请求超时的例子:不是重构。

      重构的定义是:在不改变行为的情况下改变代码结构。

      这就好比说崩溃是一次糟糕的着陆。

      1. 根据你的定义。如果你使用了它,就需要付出很多努力,却收效甚微。我见过的最好的重构都是在改善行为的同时让代码更简洁。

      2. > 重构不会改变行为。根据定义。

        重构不会改变外部行为。如果不能改变内部行为,就不能降低复杂性。

        因此,考虑到这一点…

        – 更改隐式缓存 => 重构

        – 改变隐式超时 => 重构

        – 改变显式缓存 => 不止重构

        – 改变显式超时 => 不止重构

        由于 “外部 ”一词意味着概念上的界限,因此我个人也会按层次来区分重构:

        – 系统设计

        – 服务设计

        – 程序设计

        – 组件设计

        – 模块设计

        – 功能设计

        ……本博文只讨论后两者。

      3. “顾名思义 “就是用一种大多数人都不会同意的方式来定义这个词。好吧,伙计。你的定义就是个屁。写这篇文章的人不同意你的观点,我也不同意你的观点。当我谈到简化应用程序,通过简化和改进设计使代码变得更简单时,我仍然会使用重构(refactor)这个词。

        1. 我认为重构不应改变行为,这一点已被广泛接受。至少我是这么认为的。

          1. 我想这是真的,即使在维基百科上也是如此。那我得停止使用这个词了。不过我敢肯定,大多数人在日常使用时都会更加随意。

            1. 我认为,这个隐喻是,您的代码是一个数学函数,为了更具体和 “玩具化”,我们假设它是一个多项式。如果旧代码是

              x^2 + x z + y x + y z

              那么你会发现,你可以将同一个多项式表示为

              (x + y)*(x + z)

              它仍然是同一个多项式,只是你 “把关注点分开”,把它变成了两个更简单因数的乘积。

              类似的想法也适用于元组集合。也许你会得到

              {(1, 1), (1, 4), (2, 1), (2, 4), (3, 1), (3, 4)}

              你会发现这可以更简单地表示为笛卡尔积:

              {1, 2, 3} x {1, 4}

              这又是一个字面的因式分解。你可以想象一下,这种想法的变体将如何应用于数据库表格和数据结构。

              这就是我认为 “重构 ”一词的由来。

              1. 如果改变多项式,就会改变计算顺序,从而改变行为。如果是整数,可能会出现溢出,如果是浮点数,可能会出现不同的精度。

                很高兴知道这个词的来源。词语的含义往往会随着时间的推移而改变。例如,今天的 bootstrapping 中不需要涉及马。

                1. 他说 “我认为”,暗示他并不完全确定词源。如果你感兴趣,维基百科上有关于重构的历史部分。

                2. 多项式是一个数学对象。数学中没有整数溢出或浮点不精确。

                  1. 你说得没错,但事实上数学只是达到目的的一种手段,是一种模型,并不完全等同于模拟或数字电子技术在现实世界中的最终实现。

              2. 老兄,谢谢你的解释,但我理解这个概念,只是在定义上没有达成一致。

                1. 当然,你在论坛上发表的帖子有一半是给其他人看的吧?我只是觉得考虑一下背后的隐喻很有意思;也许人们并没有想到这一点。隐喻、内涵、词源–我觉得这些都很有趣。

                  1. 当然,我同意。你甚至可以完全自私地回应我–把我当作你形成论点的垫脚石,这没什么不对。

  26. 第二个例子:糟糕的重构不也会修改用户列表吗?

  27. 不错,但后来发现是他们人工智能的广告

    1. 也不是特别好,所以总的来说就是个广告。

  28. 第一个好的重构例子充其量只是一个普通的重构,甚至可能是一个糟糕的重构。在 javascript 中,map 或 filter 等数组方法并不 “更传统”;它们和 for-loop 一样 “传统”,甚至可以说不那么 “传统”,因为 for-loop 自语言问世以来就一直存在。此外,它们还不可避免地比 for-loop 更昂贵(每个循环都会创建一个匿名函数;一个映射后跟一个过滤器意味着数组的又一次迭代)。原来的例子很好,没有必要 “重构 ”它。

    1. > 每个循环都会创建一个匿名函数

      不,不是这样的。函数在调用前会进行一次评估,并作为参数传递,然后在内部重复使用。

      另外,你在微优化。优先考虑所谓的性能而不是可读性。

      没错,for-loops 和可变结构比 map-filter-reduce 更容易出错。原文还可以,但可以做得更好。

      1. > 不,不是这样的。函数在调用前会评估一次,并作为参数传递,然后在内部重复使用。

        是的,对不起;你当然是对的。

    2. 与结果数组相比,filter 和 map 的可读性和可扩展性要好得多。此外,它还消除了债券外索引。

      请看变量名。为了与结果数组的风格保持一致,它被强制命名为 “result”。因此,它缺少一个描述性的名称。

      对于函数式方法,你可以很容易地将 filter(age > 18) 的结果赋值给一个中间变量,如 adultUsers,从而使代码更具描述性。这在步骤较多时非常有用。如果使用结果数组方法,您就必须重复循环代码或将描述深埋在循环本身中,因此您通常会避免这样做。

      1. > 更易读

        这取决于偏好。

        过滤器和映射都复制数组不是会增加 GC 压力吗?

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注