在编程过程中常见的代码写法有下面两种,我们通过一个示例去说明这两种写法的优缺点,以及我们更推荐使用那种方式去实现。
方法一:
class AddItemHandler1 {
constructor(readonly ticket: TicketState, readonly menuItem: MenuItem) {
}
handle() {
const orderItem = new OrderItemFactory(this.menuItem);
this.ticket.items.push();
}
}
方法二:
class AddItemHandler2 {
handle(payload: {
ticket: TicketState;
menuItem: MenuItem;
}) {
const orderItem = new OrderItemFactory(this.menuItem);
payload.ticket.items.push(orderItem)
}
}
第一种:类的内部属性的方式
特点:
- 面向对象风格:类通过构造函数接收依赖并存储为内部属性,这种方式符合面向对象编程(OOP)的设计理念。
- 状态性:类实例可以保存上下文(
ticket和menuItem),便于在实例生命周期内多次使用。 - 封装性:将依赖封装在对象中,不需要在调用方法时显式传入。
优点:
- 可读性好:调用时更简洁直观,例如:
const handler = new AddItemHandler1(ticket, menuItem);
handler.handle();
- 扩展性强:如果后续需要添加更多方法或属性,这种写法较为自然。
- 便于维护复杂逻辑:当
AddItemHandler1需要管理多个依赖并在多种方法中使用时,内部属性可以减少参数传递。
缺点:
- 适用范围有限:对于简单的、一次性操作,显得有些重量级。
- 测试略麻烦:单元测试需要创建完整的对象实例,可能会显得冗余。
- 潜在副作用:类属性的持久化状态可能导致意外的副作用。
- 内存占用高:不同的ticket、menu item都需要实例化出新的对象去处理。
第二种:更像是纯函数的方式
特点:
- 函数式风格:类的方法不依赖于内部状态,仅通过参数进行操作。
- 无状态性:类本身不存储状态,每次调用完全依赖于输入参数。
优点:
- 灵活性强:无需创建实例就能直接调用,例如:
const handler = new AddItemHandler2();
handler.handle({ ticket, menuItem });
或者完全移除类,仅保留为独立函数:
function addOrderItemToTicket({ ticket, menuItem }: { ticket: TicketState; menuItem: MenuItem }) {
const orderItem = new OrderItemFactory(this.menuItem);
payload.ticket.items.push(orderItem)
}
- 易测试:无状态特性使得测试只需要传入不同的输入,便于构建测试用例。
- 低耦合:方法与上下文解耦,更容易复用。
- 内存占用低:因其可以作为纯函数,所以可以只初始化一次。
缺点:
- 可读性稍差:如果依赖的参数较多,调用时需要显式地组装对象。
- 扩展性较差:如果操作逻辑需要访问大量依赖,参数对象可能变得复杂,不如内部属性直观。
适用场景:
- 偏好函数式编程风格的代码库。
- 当操作是无状态且与上下文解耦的(例如,通用工具函数)。
- 简单的、一次性的逻辑操作。
3. 选择建议
| 场景 | 推荐写法 |
|---|---|
| 操作逻辑复杂、需要保存状态或依赖 | 第一种 |
| 操作简单、无状态,适合工具函数 | 第二种 |
| 代码库的整体风格偏函数式编程 | 第二种 |
| 更注重封装性和扩展性 | 第一种 |
1. 为什么第二种方式更合适?
a. 内存占用更低
- 第一种方式(AddItemHandler1): 每次处理新的
ticket或menuItem都需要创建一个新的实例,实例会将这两个对象存储为内部属性。如果同时存在大量的ticket和menuItem,将导致许多无用实例堆积在内存中,尤其当实例生命周期较短但需要频繁创建时,这种开销会更明显。 - 第二种方式(AddItemHandler2): 只需要实例化一次,甚至可以直接使用静态方法或者纯函数的形式,完全避免实例化的内存开销。
class AddItemHandler2 {
static handle(payload: { ticket: TicketState; menuItem: MenuItem }) {
const orderItem = new OrderItemFactory(this.menuItem);
payload.ticket.items.push(orderItem)
}
}
// 使用时:
AddItemHandler2.handle({ ticket, menuItem });
b. 函数式思想更简洁
- 第二种方式与函数式编程的理念一致:无状态、纯输入输出。这种设计简洁、灵活且便于复用,避免了第一种方式中构造函数传递参数和保存内部状态的额外步骤。
c. 线程安全性
- 第二种方式更容易保证线程安全,因为它没有持久化状态。无论何时调用
handle,只依赖输入数据,彼此之间不会干扰。 - 第一种方式的状态保存在实例中,如果实例被意外共享或重用,可能会引发状态泄漏或竞争问题。
d. 单一职责原则(SRP)
- 第二种方式中的
handle方法单一且清晰:接收ticket和menuItem,完成处理后即返回。 - 第一种方式将依赖的构造、保存和处理混杂在一个类中,稍显冗余。
2. 关于内存问题的分析
第一种方式的内存开销
- 假设每个
AddItemHandler1保存了一个ticket和menuItem,随着业务量增长,实例数量成倍增加。 - 如果
ticket和menuItem是较大的对象(例如包含大量数据的复杂结构),这些实例的总内存占用会显著上升。 - 同时,垃圾回收(GC)需要定期回收这些短生命周期的实例,会导致额外的性能开销。
第二种方式的优化
- 第二种方式完全避免了为每次操作创建独立实例的需求,
handle方法只在运行时处理输入数据,内存占用更可控。 - 如果将其设计为静态方法或纯函数,甚至可以减少类本身的存储需求。
3. 综合分析:绝大多数场景的推荐实践
| 特性 | 第一种(AddItemHandler1) | 第二种(AddItemHandler2) |
|---|---|---|
| 内存占用 | 高,每个任务需要实例化对象并存储状态 | 低,无状态操作,仅依赖参数 |
| 适用场景 | 长生命周期的复杂操作,依赖状态封装 | 短生命周期的无状态操作,或高频简单任务 |
| 可扩展性 | 适合扩展复杂方法,但操作代码集中在一个类中 | 灵活扩展,符合函数式思想 |
| 调用简洁性 | 较简单,但需要先创建实例 | 直接调用方法(或静态方法),不需要实例化 |
| 线程安全性 | 较弱,依赖实例状态 | 强,无状态天然线程安全 |
因此,如果你的场景主要是处理高频、独立、无状态的任务,第二种方式不仅在性能、内存上更优,还具有更高的简洁性和可维护性。
4. 实践建议
如果确实发现业务场景绝大部分符合第二种方式的特点,可以考虑直接将 AddItemHandler2 设计为一个纯函数工具类,甚至可以移除类的结构,直接用独立函数来实现,例如:
纯函数实现
function handleTicketItem(ticket: TicketState, menuItem: MenuItem): void {
const orderItem = new OrderItemFactory(this.menuItem);
ticket.items.push(orderItem)
}
// 使用时:
handleTicketItem(ticket, menuItem);
静态方法实现
如果需要在更大的项目中管理这类函数,可以用一个静态工具类来组织:
class TicketHandler {
static addItem(ticket: TicketState, menuItem: MenuItem): void {
const orderItem = new OrderItemFactory(this.menuItem);
ticket.items.push(orderItem)
}
}
// 使用时:
TicketHandler.addItem(ticket, menuItem);
这种方式既保留了函数式编程的简洁,又满足了面向对象的组织需求,兼具灵活性和结构性。
5. 总结
大多数场景更适合第二种方式,因为它更高效、简洁且灵活。如果担心第一种方式的内存开销、线程安全或实例管理问题,可以优先选择第二种,甚至进一步优化为纯函数实现。这样不仅代码更符合现代开发的实践,还能在性能和可维护性上获益。 😊
为什么第二种方式更容易实现依赖注入?
第一种方式(AddItemHandler1)的限制
- 强绑定依赖:
AddItemHandler1的依赖(ticket和menuItem)通过构造函数直接传入并存储为类的内部属性。这种设计使得实例在创建时就绑定了具体的依赖,无法轻松替换。 - 依赖重用性差:如果需要对不同的
ticket或menuItem进行处理,就不得不重新创建新的实例,这限制了依赖的动态性。 - 对测试不友好:在测试时,需要实例化整个类并提供所有依赖,即便有些方法并不需要这些依赖。
第二种方式(AddItemHandler2)的优势
- 无状态设计:
AddItemHandler2的方法通过参数显式传递依赖,不绑定到类实例中,这种无状态设计让依赖注入变得简单。 - 动态注入依赖:只需要在调用方法时提供参数,参数可以来源于不同的上下文或配置,而无需创建新的实例。例如:
const mockTicket = { items: [] };
const mockMenuItem = { id: 1, name: "Test Item" };
handler.handle({ ticket: mockTicket, menuItem: mockMenuItem });
这使得依赖注入的实现非常轻松。
- 灵活的依赖管理:第二种方式允许你从外部统一管理依赖,通过注入不同的依赖,可以轻松调整逻辑行为。例如,可以通过工厂方法或依赖注入容器动态生成依赖,适配不同场景。
1. 结合依赖注入的改进设计
结合 DI 容器
如果项目中使用了依赖注入框架(如 InversifyJS 或 NestJS),第二种方式更加契合其设计。以下是一个示例:
import { injectable, inject } from "inversify";
@injectable()
class TicketService {
@injectable()
readonly orderItemFactory: OrderItemFactory;
handle(ticket: TicketState, menuItem: MenuItem) {
const orderItem = this.orderItemFactory.create(menuItem);
ticket.items.push(orderItem);
}
}
// 使用依赖注入容器:
const container = new Container();
container.bind<OrderItemFactory>(OrderItemFactory).toSelf();
container.bind<TicketService>(TicketService).toSelf();
const ticketService = container.get(TicketService);
ticketService.handle(ticket, menuItem);
这种设计完全符合依赖注入的核心思想,依赖通过外部容器管理和传递,而不是由类自己构造或维护。
函数式编程的依赖注入
如果进一步简化为纯函数,可以通过参数实现依赖注入,例如:
function handleTicketItem(ticket: TicketState, menuItem: MenuItem, factory: (menuItem: MenuItem) => OrderItemState): void {
const orderItem = factory(menuItem);
ticket.items.push(orderItem);
}
// 注入依赖:
handleTicketItem(ticket, menuItem, (menuItem: MenuItem): OrderItemState => {
return {
id: menuItem.id,
name: menuItem.name,
}
});
通过这种设计,你甚至可以轻松为函数添加新的依赖,而不需要修改函数的核心逻辑。
2. 为什么依赖注入如此重要?
a. 提高测试能力
- 测试代码时,可以传入 mock 对象 或 模拟依赖。
- 第二种方式中,测试只需要组装一个简单的参数对象,而不需要创建复杂的类实例。例如:
const mockTicket = { items: [] };
const mockMenuItem = { id: 1 };
const mockFactory = (menuItem: MenuItem) => {
return {
id: menuItem.id
}
}
handleTicketItem(mockTicket, mockMenuItem, mockFactory);
const mockOrderItem = mockFacoty(mockMenuItem);
expect(mockTicket.items).toContain(mockOrderItem);
b. 提高代码灵活性
- 可以在运行时动态替换依赖,而不需要修改类的实现。
- 例如,在不同环境(如开发、测试、生产)下,可以注入不同的依赖,如日志工具、配置等。
c. 避免强耦合
- 第二种方式避免了对固定依赖的强绑定,更容易调整代码逻辑。
- 例如,如果业务逻辑从直接修改
**ticket.items**转变为通过服务类管理,你只需要替换传入的依赖。
3. 结合第一种方式的场景分析
虽然第一种方式在依赖注入上显得笨重,但也有一些适合的场景:
- 复杂逻辑的封装:当类需要处理多个操作,依赖注入的成本可能更高,而封装到类中可以更好地管理。
- 需要状态持久化的场景:如果
ticket和menuItem是需要频繁访问的状态,将它们存储在类的实例中可能更高效。
不过,即便在这种场景中,也可以通过工厂模式或者容器注入优化实例的创建过程。
4. 总结
- 第二种方式因其无状态设计,更符合现代依赖注入的最佳实践,能够大幅提高代码的灵活性、可维护性和测试能力。
- 如果项目需要使用依赖注入框架(如
**InversifyJS**或**NestJS**),第二种方式更容易与这些框架集成。 - 最佳实践建议:
- 优先使用无状态函数或静态方法;
- 结合依赖注入容器动态管理依赖;
- 在需要状态管理的特殊场景下,再考虑第一种方式,但配合工厂模式优化实例管理。