函数式编程在 Java 和 Go 中的应用

函数式编程在 Java 和 Go 中的应用

·

7 min read

函数式编程是一种 "编程范式"(programming paradigm),就是如何编写程序的方法论。

函数式编程特点:

  1. 函数是"第一等公民"

  2. 只用"表达式",不用"语句" "表达式"(expression)是一个单纯的运算过程,总是有返回值;"语句"(statement)是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。

  3. 没有"副作用" 所谓"副作用"(side effect),指的是函数内部与外部互动(最典型的情况,就是修改全局变量的值),产生运算以外的其他结果。 函数式编程强调没有"副作用",意味着函数要保持独立,所有功能就是返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。

  4. 不修改状态 变量往往用来保存状态,函数式编程只是返回新的值,不修改系统变量,因此不修改状态。函数式编程使用参数保存状态,例如递归。

  5. 引用透明 引用透明(Referential transparency),指的是函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。(幂等的)

为什么使用函数式编程;

  1. 代码简洁,开发快速

  2. 接近自然语言,易于理解

  3. 易于"并发编程"

Go 中的函数式编程

Go 没有 lambda 表达式,直接定义匿名函数作为变量

func main() {
    f := func(word string) {
        fmt.Println(word)
    }
    f("test!")
}

接口型函数

接口型函数的价值在于:

  • 既能够将普通的函数类型(需类型转换)作为参数,也可以将结构体作为参数, 使用更为灵活,可读性也更好

接口型函数要求接口只有一个方法 接口型函数在调用实现的接口方法 时调用的就是接口型函数自身

// 接口
type Getter interface {
    Get(key string) ([]byte, error)
}

// 函数类型,实现了 Getter
// 函数类型实现接口,称为接口型函数
// 使用者在调用时既能传入函数作为参数,也能传入实现了该接口的结构体作为参数
type GetterFunc func(key string) ([]byte, error)

// 实现了接口 Getter 的方法
func (f GetterFunc) Get(key string) ([]byte, error) {
    return f(key)
}


// 结构体实现 Getter 接口
type DB struct {
    url string
}

func (db *DB) Get(key string) ([]byte, error) {
    return []byte(key + db.url), nil
}

func GetFromSource(getter Getter, key string) []byte {
    buf, err := getter.Get(key)
    if err != nil {
        return nil
    }
    return buf
}
// 接口型函数的价值在于:
// 既能够将普通的函数类型(需类型转换)作为参数,也可以将结构体作为参数
// 使用更为灵活,可读性也更好
func TestImplementFunc(t *testing.T) {
    // 使用接口型函数,匿名函数类型换转为 GetterFunc,GetterFunc 是实现了 Getter 接口的接口型函数
    // 既可以使用匿名函数,也可以使用具名函数,只要参数和返回值与 Getter 接口的 Get 函数相同即可
    // 接口型函数要求接口只有一个方法
    // 接口型函数在调用实现的接口方法 Get 时调用的就是接口型函数自身
    GetFromSource(GetterFunc(func(key string) ([]byte, error) {
        return []byte(key), nil
    }), "key")

    // 也可以传入实现了 Getter 接口的结构体
    // 适用于逻辑复杂的请求,比如数据库访问等需要很多参数的情况
    GetFromSource(new(DB), "key")
}
http 包中的示例

例如在 http 中的 Handle 方法和 HandleFunc 方法中的第二个参数 Handler 就是一个接口,这个接口既可以传入接口型函数,也可以传入实现接口的结构体

func Handle(pattern string, handler Handler) {
    DefaultServeMux.Handle(pattern, handler)
}
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    DefaultServeMux.HandleFunc(pattern, handler)
}

Handler 接口

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

实现了 Handler 接口的接口型函数 HandlerFunc 实现了 ServeHTTP 方法

type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

其中 Handle 和 HandleFunc 函数是相同的,因为 HandleFunc 函数将函数转换为了 HanderFunc 接口型函数

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    if handler == nil {
        panic("http: nil handler")
    }
    mux.Handle(pattern, HandlerFunc(handler))
}

这两个函数都是通过 ServMux 的 Handle 函数执行的,第二参数是 Handler 接口

func (mux *ServeMux) Handle(pattern string, handler Handler)
类似于 Java 中的函数式接口

Java 中的函数式接口也是只有一个方法。lambada 表达式、匿名函数相当于实现了这个函数式接口中的这个函数。

Java 中的函数式编程

Java 中无法直接定义函数,Java 中的函数式编程几乎等同于 lambda 表达式!

Lambda 表达式、流式编程都是 Java 8 才有的,lambda 表达式主要应用于流式编程中。

Random random = new Random(47);
long count = Stream.generate(() -> random.nextInt(10))
        .limit(10)
        //.peek((x) -> System.out.println(x))
        .peek(System.out::println)
        .count();
System.out.println(count);

Lambda 表达式 ()->{} 方法列表和方法体,省略方法名称,参数和返回值类型都是可以自动推断的

方法引用是用来简写 lambda 表达式的,方法引用只看传入参数和返回值,不看函数名称!

函数式接口

函数式接口的特点: 每个接口有且仅有一个抽象方法,称为函数式方法,这个接口叫做函数式接口。 @FunctionalInterface 注释保证有且仅有一个抽象方法。

Stream 中的 generate 函数需要一个 Supplier 类型的对象,其中 Supplier 就是函数式接口。

public static<T> Stream<T> generate(Supplier<T> s) {
    Objects.requireNonNull(s);
    return StreamSupport.stream(
            new StreamSpliterators.InfiniteSupplyingSpliterator.OfRef<>(Long.MAX_VALUE, s), false);
}
@FunctionalInterface
public interface Supplier<T> {
    T get();
}

Stream 中的 peek 方法需要一个 Consumer 类型的对象,Consumer 也是一个函数式接口。

Stream<T> peek(Consumer<? super T> action);
@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);
    ....
}

可以将 lambda 表达式和方法引用赋值给函数式接口

Random random = new Random(47);
Supplier<Integer> s = () -> random.nextInt(10);
Consumer<Integer> c = System.out::println;

Java 8 引入 java.util.function 包,定义了许多函数式接口。

闭包

  1. 封装性:闭包可以将函数和它的环境封装在一起(变量捕获),使得函数可以访问它的环境中的变量,而无需暴露给外界。

  2. 动态作用域:闭包可以在运行时动态的改变它的作用域,使得代码具有更高的灵活性和可扩展性。

Java 中的闭包

i 有单独的生命周期

class Closure1 {
    int i;
    IntSupplier makeFun(int x) {
        return () -> x + i++;
    }
}
class SharedStorage {
    public static void main(String[] args) {
        Closure1 c1 = new Closure1();
        IntSupplier f1 = c1.makeFun(0);
        IntSupplier f2 = c1.makeFun(0);
        IntSupplier f3 = c1.makeFun(0);
        System.out.println(f1.getAsInt());
        System.out.println(f2.getAsInt());
        System.out.println(f3.getAsInt());
    }
}

Java 闭包中的变量必须是不可变的。用 final 修饰或者等同 final 的。

class Closure2 {
    IntSupplier makeFun(int x) {
        int i = 0;
        //return () -> x++ + i++;   // x++ 和 i++ 都会报错
        //x++;  // 报错
        //i++;  // 报错
        return () -> x + i;
    }
}

Go 中的闭包

Go 语言中闭包不需要要求被捕获的变量必须是不可变的

func HighFunction() func() int {
    i := 0
    return func() int {
        i++
        return i
    }
}

func main() {
    fmt.Printf("%#v \n", HighFunction()())
}

capture-by-value vs capture-by-reference

Python 和 go 函数式编程中的闭包都是 capture-by-reference,在闭包中可以直接修改外部变量的值,外部变量和闭包中的变量指向的是同一块内存

Java 闭包是 capture-by-value,值传递通过 copy 方式传进闭包,闭包中使用的变量和闭包外的变量指向不同的内存,因此闭包外的变量需要是 final 的。如果不是 final 的话,在闭包内修改变量不会影响闭包外的变量,导致数据不一致

  • 值捕获(capture-by-value):只需要在创建闭包的地方把捕获的值拷贝一份到对象里即可。Java 的匿名内部类和 Java 8 新的 lambda 表达式都是这样实现的。

  • 引用捕获(capture-by-reference):把被捕获的局部变量“提升”(hoist)到对象里。C#的匿名函数(匿名委托/lambda 表达式)就是这样实现的。

闭包导致的 for 循环问题

for range 中的问题,for range 语法会将 nums 中的值全部赋值给同一块内存地址 n,fmt.Printf("%#v \n", &n) 可以看到所有 n 的地址都是同一个地址!funcs 函数数组中的所有函数都没有被执行,n 的值被包含在函数闭包中被延迟执行了,所有函数的指针的值都指向同一个地址。

func main() {
    nums := []int{1, 2, 3, 4, 5}
    var funcs []func()
    for _, n := range nums {
        // num := n
        funcs = append(funcs, func() {
            fmt.Printf("%v ", n)
        })
        // funcs = append(funcs, func() {
        //     fmt.Printf("%v ", num)
        // })
    }
    for _, f := range funcs {
        f()
    }
}
/*
5 5 5 5 5
*/
func main() {
    nums := []int{1, 2, 3, 4, 5}
    var funcs []func()
    for i := 0; i < len(nums); i++ {
        // num := nums[i]
        funcs = append(funcs, func() {
            fmt.Printf("%v ", nums[i])
        })
        // funcs = append(funcs, func() {
        //     fmt.Printf("%v ", num)
        // })
    }
    for _, f := range funcs {
        f()
    }
}
// 报错,因为 i 最后 ++ 后越界了

协程中也会遇到这种问题:

func main() {
    nums := []int{1, 2, 3, 4, 5}
    for _, num := range nums {
        go func() {
            fmt.Printf("%v ", num)
        }()
        // 方法一
        // n := num
        // go func() {
        //     fmt.Printf("%v ", n)
        // }()
        // 方法二
        // go func(n int) {
        //     fmt.Printf("%v ", n)
        // }(num)
    }
    time.Sleep(1 * time.Second)
}

如果传递参数是指针的话,指针复制,所有协程中的指针指向同一块内存,这是 for range 的问题,不是闭包的问题

func main() {

    nums := []int{1, 2, 3, 4, 5}
    for _, num := range nums {
        nPtr := &num
        go func(p *int) {
            fmt.Printf("%v ", *p)
        }(nPtr)
    }
    time.Sleep(1 * time.Second)
}

使用指针的时候要小心!

Map Reduce

类似于 Java 的策略设计模式

Map

将 for 循环代码复用,多个 map 方法实现

type Option func(word string) string

func UpperWord(word string) string {
    return strings.ToUpper(word)
}
func LowerWord(word string) string {
    return strings.ToLower(word)
}
func MapString(words []string, option Option) []string {
    var ans []string
    for _, word := range words {
        ans = append(ans, option(word))
    }
    return ans
}

func main() {
    words := strings.Split("My name is test", " ")
    words = MapString(words, UpperWord)
    fmt.Printf("%#v \n", words)
    words = MapString(words, LowerWord)
    fmt.Printf("%#v \n", words)
}

Reduce

统计字符串一共多少个字符

func Reduce(words []string, f func(string) int) int {
    ans := 0
    for _, word := range words {
        ans += f(word)
    }
    return ans
}

func main() {
    words := strings.Split("My name is test", " ")

    cnt := Reduce(words, func(word string) int {
        return len(word)
    })
    fmt.Printf("%#v \n", cnt)
}

Filter

过滤偶数

func Filter(nums []int, f func(num int) bool) []int {
    var ans []int
    for _, num := range nums {
        if f(num) {
            ans = append(ans, num)
        }
    }
    return ans
}
func main() {
    nums := []int{1, 2, 3, 4, 5, 6, 7}
    ans := Filter(nums, func(num int) bool {
        if num%2 == 0 {
            return true
        }
        return false
    })
    fmt.Printf("%#v \n", ans)
}

Functional Options

通用的两种方式:

type MyStruct struct {
    FirstName  string
    SecondName string
}

// 封装一下构造函数,让构造代码重用
func NewMyStruct(firstName string, secondName string) MyStruct {
    return MyStruct{
        FirstName:  firstName,
        SecondName: secondName,
    }
}

func main() {

    // 每次构建对象都十分复杂
    s1 := MyStruct{}
    s2 := MyStruct{
        FirstName: "firstName",
    }
    s3 := MyStruct{
        FirstName:  "firstName",
        SecondName: "secondName",
    }
    fmt.Printf("%#v \n", s1)
    fmt.Printf("%#v \n", s2)
    fmt.Printf("%#v \n", s3)

    // 输入无意义的字段
    ms1 := NewMyStruct("", "")
    ms2 := NewMyStruct("firstname", "")
    ms3 := NewMyStruct("firstname", "secondname")

    fmt.Printf("%#v \n", ms1)
    fmt.Printf("%#v \n", ms2)
    fmt.Printf("%#v \n", ms3)
}
type MyStruct struct {
    FirstName  string
    SecondName string
}

type Option func(s *MyStruct)

func WithFirstName(firstName string) Option {
    return func(s *MyStruct) {
        s.FirstName = firstName
    }
}
func WithSecondName(secondName string) Option {
    return func(s *MyStruct) {
        s.SecondName = secondName
    }
}
func NewMyStruct(options ...Option) MyStruct {
    s := &MyStruct{}
    for _, option := range options {
        option(s)
    }
    return *s
}
func main() {
    ms1 := NewMyStruct()
    ms2 := NewMyStruct(WithFirstName("firstname"))
    ms3 := NewMyStruct(WithFirstName("firstname"), WithSecondName("secondname"))
    fmt.Printf("%#v \n", ms1)
    fmt.Printf("%#v \n", ms2)
    fmt.Printf("%#v \n", ms3)
}

柯里化

柯里化意为:将一个多参数的函数,转换为一系列单参数函数。

func Add(x int, y int) int {
    return x + y
}

func Curry(x int) func(y int) int {
    return func(y int) int {
        return x + y
    }
}

func main() {
    fmt.Printf("%#v \n", Add(1, 1))
    fmt.Printf("%#v \n", Curry(1)(1))
}

尾递归优化

尾递归调用(Tail Call)是函数式编程的一个重要概念,指某个函数的最后一步是调用另一个函数。

递归非常耗费内存,因为需要同时保存成千上百个调用记录,很容易发生"栈溢出"错误(stack overflow)。但对于尾递归来说,由于只存在一个调用记录,所以永远不会发生"栈溢出"错误。

计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度 O(n) 。如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。

func fib(n int) int {
    if n == 1 {
        return 1
    }
    return n * fib(n-1)
}
func fib2(n int, total int) int {
    if n == 1 {
        return total
    }
    return fib2(n-1, n*total)
}

func main() {
    fmt.Printf("%#v \n", fib(10))
    fmt.Printf("%#v \n", fib2(10, 1))
}