在 C 或 C++ 等语言中,我们可以将函数存储在变量中并传递——这被称为函数指针。Java 没有函数指针,但我们可以使用其他技术实现相同的行为。在本教程中,我们将探讨几种在 Java 中模拟函数指针的常见方法。
在 Java 8 之前,模拟函数指针的标准方式是定义单方法接口,并通过匿名类实现。这种方法在维护旧代码或在不支持 Java 8+ 的环境中仍然很有价值。
以下是我们如何定义一个操作接口:
public interface MathOperation {
int operate(int a, int b);
}
该接口只有一个方法 operate(),接收两个整数并返回结果。
现在我们定义一个使用该接口的类:
public class Calculator {
public int calculate(int a, int b, MathOperation operation) {
return operation.operate(a, b);
}
}
calculate() 方法接收一个 operation 并将计算逻辑委托给传入的实现。
让我们使用匿名类测试加法:
@Test
void givenAnonymousAddition_whenCalculate_thenReturnSum() {
Calculator calculator = new Calculator();
MathOperation addition = new MathOperation() {
@Override
public int operate(int a, int b) {
return a + b;
}
};
int result = calculator.calculate(2, 3, addition);
assertEquals(5, result);
}
在这段代码中,接口通过匿名类直接实现。这允许将行为传入 Calculator。测试确认 2 + 3 的结果是 5。
接口方式适用于所有 Java 版本,并提供清晰的类型安全。
然而,它需要大量样板代码,尤其是对于简单操作。每个操作都需要自己的类实现,这可能会使代码库因大量小类而变得杂乱。
Java 8 引入了 lambda 表达式,提供了一种更短、更易读的方式来传递行为。
我们可以在这里复用相同的 MathOperation 接口:
@Test
void givenLambdaSubtraction_whenCalculate_thenReturnDifference() {
Calculator calculator = new Calculator();
MathOperation subtract = (a, b) -> a - b;
int result = calculator.calculate(10, 4, subtract);
assertEquals(6, result);
}
在这个测试中,我们使用 lambda 执行减法。表达式 (a, b) -> a - b 内联定义了逻辑,并与接口的方法签名匹配。
Calculator 没有变化——它仍然接收接口并调用其方法。不同之处在于行为现在以更简洁的方式传递。
这种方法广泛用于现代 Java 代码中。它提高了可读性并减少了样板代码,尤其是在执行简单操作时。
此外,Java 8 还引入了 java.util.function 包中的预定义函数式接口。这些允许我们避免编写自己的接口。
让我们使用一个名为 BiFunction 的内置接口,它接受两个输入并返回一个结果:
@Test
void givenBiFunctionMultiply_whenApply_thenReturnProduct() {
BiFunction<Integer, Integer, Integer> multiply = (a, b) -> a * b;
int result = multiply.apply(6, 7);
assertEquals(42, result);
}
BiFunction<T, U, R> 表示一个接受两个参数并返回一个结果的函数。我们将逻辑存储在变量 multiply 中,并使用 apply() 方法调用它。
我们也可以在方法中使用 BiFunction:
public class AdvancedCalculator {
public int compute(int a, int b, BiFunction<Integer, Integer, Integer> operation) {
return operation.apply(a, b);
}
}
让我们使用 BiFunction 方法测试除法:
@Test
void givenBiFunctionDivide_whenCompute_thenReturnQuotient() {
AdvancedCalculator calculator = new AdvancedCalculator();
BiFunction<Integer, Integer, Integer> divide = (a, b) -> a / b;
int result = calculator.compute(20, 4, divide);
assertEquals(5, result);
}
使用内置接口可以使代码保持简洁,避免额外的样板代码。当函数需求与预定义接口(如 Function、BiFunction 或 Predicate)匹配时,这种方法效果很好。
当我们希望函数定义标准化和一致性时,这种模式是理想的。然而,当我们需要自定义参数或返回类型不符合这些预定义类型时,可能会面临限制。
方法引用为调用现有方法的 lambda 表达式提供了简写形式。
让我们定义一个加法工具方法:
public class MathUtils {
public static int add(int a, int b) {
return a + b;
}
}
我们现在可以使用方法引用而不是编写 lambda:
@Test
void givenMethodReference_whenCalculate_thenReturnSum() {
Calculator calculator = new Calculator();
MathOperation operation = MathUtils::add;
int result = calculator.calculate(5, 10, operation);
assertEquals(15, result);
}
在这段代码中,MathUtils::add 作为引用传递。方法签名与 MathOperation 中的 operate() 方法匹配,因此编译器接受它。
当我们已经有现有的静态或实例方法时,方法引用非常有用。它们通过避免重复使代码更简洁,在流操作或回调模式中特别有用。
当我们已经存在逻辑时,这种方法最有效。但如果行为需要动态或自定义,lambda 或接口可能提供更灵活的解决方案。
Java 还允许使用反射动态调用方法。这更高级,通常用于框架、工具或库中,其中方法必须在运行时发现和调用。
让我们定义一个动态操作方法:
public class DynamicOps {
public int power(int a, int b) {
return (int) Math.pow(a, b);
}
}
现在让我们通过反射调用该方法:
@Test
void givenReflection_whenInvokePower_thenReturnResult() throws Exception {
DynamicOps ops = new DynamicOps();
Method method = DynamicOps.class.getMethod("power", int.class, int.class);
int result = (int) method.invoke(ops, 2, 3);
assertEquals(8, result);
}
在这个例子中,我们使用方法名和参数类型从 DynamicOps 类中获取 power() 方法引用,然后使用参数调用它。这允许在运行时选择和执行行为。
当我们不知道编译时方法时,反射是强大的,例如在插件系统或基于注解的处理中。然而,它比其他技术更慢且更容易出错,并且不提供编译时类型安全。
我们通常避免在一般应用程序逻辑中使用反射。它最好保留在需要动态加载或调用的特定用例中。
另一种在 Java 中模拟函数指针的方法是使用命令模式,它将行为封装到独立对象中。
当我们想要参数化操作、延迟其执行或动态排队时,这种模式特别有用。它还促进了操作调用者与逻辑本身之间的松耦合。
让我们继续使用我们的 MathOperation 示例。在这种情况下,每个数学操作都可以被视为一个命令。我们从相同的功能接口开始:
public interface MathOperation {
int operate(int a, int b);
}
现在,我们不是传递 lambda 或匿名类,而是定义实现该接口的单个命令类。例如,我们可以创建一个 AddCommand 类如下:
public class AddCommand implements MathOperation {
@Override
public int operate(int a, int b) {
return a + b;
}
}
同样,我们可以创建其他命令,如 SubtractCommand、MultiplyCommand 或 DivideCommand,每个命令封装特定的操作。
我们可以重用现有的 Calculator 类来执行这些命令。让我们使用加法操作测试命令模式:
@Test
void givenAddCommand_whenCalculate_thenReturnSum() {
Calculator calculator = new Calculator();
MathOperation add = new AddCommand();
int result = calculator.calculate(3, 7, add);
assertEquals(10, result);
}
在这里,我们创建一个特定的 AddCommand 对象并将其传递给计算器。这将加法逻辑封装在一个可重用的独立对象中,就像一个命令。
**当我们需要将不同行为作为对象传递时,这种方法很有效,尤其是在支持撤消操作、历史记录或延迟执行的架构中。**它还可以使每个操作易于单独测试和扩展。
此外,Java 枚举不仅限于表示常量;它们还可以封装逻辑。通过允许枚举定义方法,我们可以将相关行为组合在一起,并像函数指针一样传递它们。
当我们已知一组固定操作时,这尤其有效。让我们回到数学操作示例,并使用枚举实现逻辑。
我们定义一个枚举 MathOperationEnum,其中每个常量重写抽象方法以提供自己的行为:
public enum MathOperationEnum {
ADD {
@Override
public int apply(int a, int b) {
return a + b;
}
},
SUBTRACT {
@Override
public int apply(int a, int b) {
return a - b;
}
},
MULTIPLY {
@Override
public int apply(int a, int b) {
return a * b;
}
},
DIVIDE {
@Override
public int apply(int a, int b) {
if (b == 0) thrownew ArithmeticException("Division by zero");
return a / b;
}
};
public abstract int apply(int a, int b);
}
通过这种结构,每个枚举常量实际上都是自己的函数。我们可以轻松地在类似计算器的类中使用它:
public class EnumCalculator {
public int calculate(int a, int b, MathOperationEnum operation) {
return operation.apply(a, b);
}
}
让我们创建一个使用这种基于枚举的方法的简单测试:
@Test
void givenEnumSubtract_whenCalculate_thenReturnResult() {
EnumCalculator calculator = new EnumCalculator();
int result = calculator.calculate(9, 4, MathOperationEnum.SUBTRACT);
assertEquals(5, result);
}
在这个例子中,行为是通过定义操作的枚举常量传递的。这种模式提供了类型安全性,将所有可能的操作集中在一个地方,并避免了多个类文件或自定义接口的需求。
当我们有一组预定义的、有限的行为应该逻辑地组合在一起时,以这种方式使用枚举是理想的。
以下是常用方法的比较:
方法 | 优点 | 缺点 | 使用场景 |
|---|---|---|---|
接口 + 匿名类 | 适用于所有 Java 版本 | 语法冗长 | 在维护旧代码或 Java 8 之前的环境中 |
Lambda 表达式 | 简洁、现代、易读 | 仅 Java 8+ | 编写现代、简洁、可读的功能代码 |
内置函数式接口 | 无需编写自定义接口 | 限于预定义输入/输出类型 | 当常用结构如 BiFunction 或 Predicate 符合需求时 |
方法引用 | 使用现有方法的清晰语法 | 自定义逻辑灵活性较差 | 当重用与函数签名匹配的现有静态或实例方法时 |
反射 | 动态且强大 | 慢、不安全、复杂 | 当方法必须在运行时发现和调用时 |
命令模式 | 将行为封装为可重用对象 | 需要更多样板和类定义 | 当需要将操作作为对象排队、记录或参数化时 |
基于枚举的功能行为 | 类型安全,固定行为的集中定义 | 限于预定义的有限操作 | 当操作集已知且逻辑分组时 |
在本文中,我们探讨了 Java 如何使用各种技术模拟函数指针的概念。对于现代 Java 开发,lambda 表达式和内置函数式接口是最常用的方法,因为它们的简单性和可读性。
在旧版或旧代码库中,如果 Java 8 功能不可用,使用带有匿名类的接口仍然是一种可靠的替代方案。
翻译自:https://www.baeldung.com/java-function-pointers