Ohhnews

分类导航

$ cd ..
Baeldung原文

Java中模拟函数指针的多种方法

#java#函数指针#lambda表达式#函数式接口#行为传递

[LOADING...]

1. 介绍

在像 C 或 C++ 这样的语言中,我们可以将函数存储在变量中并传递它们——这被称为函数指针。Java 没有函数指针,但我们可以使用其他技术实现相同的行为。在本教程中,我们将探讨几种在 Java 中模拟函数指针的常见方法。

2. 接口和匿名类

在 Java 8 之前,模拟函数指针的标准方法是定义单方法接口并使用匿名类来实现它们。这种方法对于维护遗留代码或在不支持 Java 8+ 的环境中使用仍然很有价值。

以下是我们如何定义一个简单的操作接口:

$ java
public interface MathOperation {
    int operate(int a, int b);
}

这个接口只有一个方法 operate(),它接收两个整数并返回一个结果。

现在我们定义一个使用此接口的类:

$ java
public class Calculator {
    public int calculate(int a, int b, MathOperation operation) {
        return operation.operate(a, b);
    }
}

calculate() 方法接受一个 operation 并将计算逻辑委托给传入的实现。

让我们使用匿名类来测试加法:

$ java
@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 版本,并提供清晰的类型安全性。

然而,它需要大量的样板代码,特别是对于简单的操作。每个操作都需要自己的类实现,这可能会使代码库中充斥着许多小类。

3. Lambda 表达式 (Java 8+)

Java 8 引入了Lambda 表达式,它提供了一种更短、更易读的方式来传递行为。

我们可以重用相同的 MathOperation 接口:

$ java
@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 代码中被广泛使用。它提高了可读性并减少了样板,尤其是在执行简单操作时。

4. 内置函数式接口

此外,Java 8 还在 java.util.function 包中引入了预定义的函数式接口。这些接口使我们无需编写自己的接口。

让我们使用一个名为 BiFunction 的内置接口,它接受两个输入并返回一个结果:

$ java
@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

$ java
public class AdvancedCalculator {
    public int compute(int a, int b, BiFunction<Integer, Integer, Integer> operation) {
        return operation.apply(a, b);
    }
}

让我们使用 BiFunction 方法来测试除法:

$ java
@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);
}

使用内置接口可以使我们的代码保持整洁并避免额外的样板。当功能需求与预定义接口(如 FunctionBiFunctionPredicate)之一匹配时,这种方法效果很好。

当我们希望函数定义标准化和一致时,这种模式是理想的。然而,当我们需要不适合这些预定义类型的自定义参数或返回类型时,我们可能会面临限制。

5. 方法引用

方法引用为调用现有方法提供了 Lambda 表达式的简写形式。

让我们定义一个用于加法的实用方法:

$ java
public class MathUtils {
    public static int add(int a, int b) {
        return a + b;
    }
}

现在我们可以使用方法引用而不是编写 Lambda 表达式:

$ java
@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 表达式或接口可能提供更大的灵活性。

6. 反射

Java 还允许使用反射动态调用方法。这是一种更高级的技术,通常用于框架、工具或库中,其中方法必须在运行时被发现和调用。

让我们定义一个动态操作方法:

$ java
public class DynamicOps {
    public int power(int a, int b) {
        return (int) Math.pow(a, b);
    }
}

现在让我们通过反射调用该方法:

$ java
@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() 方法引用,然后用参数调用它。这允许在运行时选择和执行行为。

当我们不知道编译时的方法时,例如在插件系统或基于注解的处理中,反射功能强大。然而,它比其他技术更慢且更容易出错,并且不提供编译时类型安全。

我们通常在一般的应用程序逻辑中避免使用反射。最好将其保留给需要动态加载或调用的特定用例。

7. 命令模式

在 Java 中模拟函数指针的另一种方法是使用命令模式,它将行为封装到独立的类中。

当我们需要参数化操作、延迟执行或动态排队时,这种模式特别有用。它还促进了操作调用者与逻辑本身之间的松散耦合。

让我们继续使用 MathOperation 示例。在这种情况下,每个数学操作都可以被视为一个命令。我们从相同的函数式接口开始:

$ java
public interface MathOperation {
    int operate(int a, int b);
}

现在,我们不传递 Lambda 表达式或匿名类,而是定义实现此接口的单个命令类。例如,我们可以创建 AddCommand 类,如下所示:

$ java
public class AddCommand implements MathOperation {
    @Override
    public int operate(int a, int b) {
        return a + b;
    }
}

类似地,我们可以创建其他命令,如 SubtractCommandMultiplyCommandDivideCommand,每个命令都封装了特定的操作。

我们可以重用现有的 Calculator 类来执行这些命令。让我们用加法操作测试命令模式:

$ java
@Test
void givenAddCommand_whenCalculate_thenReturnSum() {
    Calculator calculator = new Calculator();
    MathOperation add = new AddCommand();
    int result = calculator.calculate(3, 7, add);
    assertEquals(10, result);
}

在这里,我们创建了一个特定的 AddCommand 对象并将其传递给计算器。这就像一个命令一样,将加法逻辑封装在一个可重用的独立对象中。

**当我们需要将不同的行为作为对象传递时,这种方法效果很好,尤其是在支持撤销操作、历史跟踪或延迟执行的架构中。**它还使每个操作可以单独轻松测试和扩展。

8. 基于枚举

此外,Java 枚举不仅限于表示常量;它们还可以封装逻辑。通过允许枚举定义方法,我们可以将相关行为组合在一起并像函数指针一样传递它们。

当我们有一组已知的固定操作时,这尤其有效。让我们回到数学运算示例,并使用枚举实现逻辑。

我们定义一个枚举 MathOperationEnum,其中每个常量都覆盖一个抽象方法以提供自己的行为:

$ java
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) throw new ArithmeticException("Division by zero");
            return a / b;
        }
    };
    public abstract int apply(int a, int b);
}

通过这种结构,每个枚举常量实际上都是它自己的函数。我们可以轻松地在类似计算器的类中使用它:

$ java
public class EnumCalculator {
    public int calculate(int a, int b, MathOperationEnum operation) {
        return operation.apply(a, b);
    }
}

让我们创建一个使用这种基于枚举的方法的简单测试:

$ java
@Test
void givenEnumSubtract_whenCalculate_thenReturnResult() {
    EnumCalculator calculator = new EnumCalculator();
    int result = calculator.calculate(9, 4, MathOperationEnum.SUBTRACT);
    assertEquals(5, result);
}

在这个例子中,行为通过枚举常量传递,它定义了操作的实现。这种模式提供了类型安全,将所有可能的操作集中在一个地方,并避免了对多个类文件或自定义接口的需求。

当我们需要一组预定义、有限且应逻辑分组的行为时,以这种方式使用枚举是理想的。

9. 总结

下面是常用方法的比较:

方法优点缺点何时使用
接口 + 匿名类适用于所有 Java 版本语法冗长处理遗留代码库或 Java 8 之前的环境时
Lambda 表达式简洁、现代、易于阅读仅限 Java 8+编写现代、简洁、可读的函数式代码时
内置函数式接口无需编写自定义接口限于预定义的输入/输出类型BiFunctionPredicate 等常见函数结构适合用例时
方法引用使用现有方法的简洁语法对自定义逻辑的灵活性较低重用与函数签名匹配的现有静态或实例方法时
反射动态且功能强大慢、不安全、复杂方法必须在运行时动态发现和调用时
命令模式将行为封装到可重用对象中需要更多样板和类定义当需要将操作排队、记录或参数化为对象时
基于枚举的函数行为类型安全且集中定义固定行为限于有限的、预定义的操作当操作集已知且逻辑上分组在一起时

10. 结论

在本文中,我们探讨了 Java 如何使用各种技术模拟函数指针的概念。对于现代 Java 开发,Lambda 表达式和内置函数式接口因其简洁性和可读性而成为最常用的方法。

在 Java 8 功能不可用的旧代码库中,使用接口和匿名类仍然是可靠的替代方案。

一如既往,源代码可在 GitHub 上获取。