你如何在函数式语言中实现面向对象的多态性?

问题描述

假设您在面向对象的应用程序中有这个:

module Talker
  def talk(word)
    puts word
  end
end

module Swimmer
  def swim(distance)
    puts "swimming #{distance}"
  end
end

class Organism
  def initialize
    rise
  end
  
  def rise
    puts "hello world"
  end
end

class Animal extends Organism
  def think(something)
    puts "think #{something}"
  end
end

class Bird extends Animal
  include Talker
end

class Fish extends Animal
  include Swimmer
end

bird = new Bird
fish = new Fish

在这里,您可以调用每个方法都唯一的方法

bird.talk("hello")
fish.swim(50)

但是你也可以调用相同的方法

bird.think("fly")
fish.think("swim")

如果我有一个接受动物的函数,我可以调用 think 函数

def experience(animal)
  animal.think("one")
  animal.think("two")
  animal.think("one")
end

在伪函数式语言中,你基本上可以这样做:

function experience(animal) {
  think(animal)
  think(animal)
  think(animal)
}

但并非如此,您必须检查类型:

function think(genericObject) {
  if (genericObject is Animal) {
    animalThink(genericObject)
  } else if (genericObject is SomethingElse) {
    somethingElseThink(genericObject)
  }
}

那是因为,在实现你的“体验”功能时,你不仅要体验动物,还要体验岩石、树木等东西,但它们的体验功能不同。

function experience(thing) {
  move(thing)
  move(thing)
  move(thing)
}

function move(thing) {
  case thing {
    match Animal then animalMove(thing)
    match Plant then plantMove(thing)
    match Rock then rockMove(thing)
  }
}

这样,你就不能有一个干净可重用的函数你的函数必须知道它将在某处接收的特定类型

有什么办法可以避免这种情况,让它更像函数式语言中的 OO 多态性吗?

如果是这样,在高层次上,如果这可以用函数式语言解决,它是如何工作的?

解决方法

函数式编程语言有多种实现多态的方法。我将比较 Java(我最了解的 OOP 语言)和 Haskell(我最了解的函数式语言)。

方式一:“参数多态”

使用参数多态性,您根本不需要了解有关底层类型的任何信息。例如,如果我有一个包含 T 类型元素的单向链表,我实际上不需要知道任何关于 T 类型的信息就可以找到列表的长度。我会写一些类似的东西

length :: forall a . [a] -> Integer
length [] = 0
length (x:xs) = 1 + length xs

在 Haskell 中(显然我想在实践中使用更好的算法,但你明白了)。请注意,列表元素的类型无关紧要;获取长度的代码是一样的。第一行是“类型签名”。它表示对于每个类型 a,length 将采用 a 的列表并输出一个整数。

这不能用于太多“严重的多态性”,但这绝对是一个强大的开始。它大致对应于 Java 的泛型。

方式二:类型类风格的多态

即使是像检查相等性这样温和的事情,实际上也需要多态性。不同的类型需要不同的代码来检查相等性,并且对于某些类型(通常是函数),由于停机问题,检查相等性实际上是不可能的。因此,我们使用“类型类”。

假设我定义了一个具有 2 个元素的新类型,Bob 和 Larry。在 Haskell 中,这看起来像

data VeggieTalesStars = Bob | Larry

我希望能够比较 VeggieTalesStars 类型的两个元素是否相等。为此,我需要实现一个 Eq 实例。

instance Eq VeggieTalesStars where
    Bob == Bob     = True
    Larry == Larry = True
    Bob == Larry   = False
    Larry == Bob   = False

注意函数(==)有类型签名

(==) :: forall b . Eq b => b -> b -> Bool

这意味着对于每个类型 b,如果 b 有一个 Eq 实例,那么 (==) 可以接受两个 b 类型的参数并返回一个 Bool。

你可能不难猜到不等于函数 (/=) 也有类型签名

(/=) :: forall b . Eq b => b -> b -> Bool

因为 (/=) 被定义为

x /= y = not (x == y)

当我们调用 (/=) 函数时,该函数将根据参数的类型部署正确版本的 (==) 函数。如果参数具有不同的类型,您将无法使用 (/=) 来比较它们。

类型类样式的多态性允许您执行以下操作:

class Animal b where
    think :: b -> String -> String
    -- we provide the default implementation
    think b string = "think " ++ string

data Fish = Fish
data Bird = Bird

instance Animal Fish where
instance Animal Bird where

Fish 和 Bird 都实现了“Animal”类型类,因此我们可以在两者上调用 think 函数。也就是说,

>>> think Bird "thought"
"think thought"
>>> think Fish "thought"
"think thought"

这个用例大致对应于 Java 接口 - 类型可以实现任意数量的类型类。但是类型类比接口强大得多。

方式 3:函数

如果你的对象只有一个方法,它也可能只是一个函数。这是避免继承层次结构的一种非常常见的方法 - 处理函数而不是 1-method 基类的继承者。

因此可以定义

type Animal = String -> String
    
basicAnimal :: Animal
basicAnimal thought = "think " ++ thought

“动物”实际上只是获取一个字符串并生成另一个字符串的一种方式。这将对应于 Java 代码

class Animal {
    public String think(String thought) {
        return "think " + thought;
    }
}

假设在 Java 中,我们决定实现一个动物的子类,如下所示:

class ThoughtfulPerson extends Animal {
    private final String thought;
    public ThoughtfulPerson(final String thought) {
        this.thought = thought;
    }

    @Override
    public String think(String thought) {
        System.out.println("I normally think " + this.thought ",but I'm currently thinking" + thought + ".");
    }
}

在 Haskell 中,我们将其实现为

thoughtfulPerson :: String -> Animal
thoughtfulPerson originalThought newThought = "I normally think " ++ originalThought ",but I'm currently thinking" ++ newThought ++ "."

Java 代码的“依赖注入”是通过 Haskell 的高阶函数实现的。

方式 4:组合优于继承 + 函数

假设我们有一个带有两个方法的抽象基类 Thing:

abstract class Thing {
    public abstract String name();
    public abstract void makeLightBlink(int duration);
}

我使用的是 Java 风格的语法,但希望不会太混乱。

从根本上说,使用这个抽象基类的唯一方法是调用它的两个方法。因此,Thing 实际上应该被认为一个由字符串和函数组成的有序对。

在像 Haskell 这样的函数式语言中,我们会写

data Thing = Thing { name :: String,makeLightsBlink :: Int -> IO () }

换句话说,一个“Thing”由两部分组成:一个名称,它是一个字符串,一个函数 makeLightsBlink,它接受一个 Int 并输出一个“IO 动作”。这是 Haskell 处理 IO 的方式——通过类型系统。

Haskell 不需要定义 Thing 的子类,而是让您定义输出 Thing 的函数(或直接定义 Things 本身)。所以如果在 Java 中你可以定义

class ConcreteThing extends Thing {
    @Override
    public String name() {
        return "ConcreteThing";
    }

    @Override
    public void makeLightsBlink(int duration) {
        for (int i = 0; i < duration; i++) {
            System.out.println("Lights are blinking!");
        }
    }
}

在 Haskell 中,您将改为定义

concreteThing :: Thing
concreteThing = Thing { name = "ConcreteThing",makeLightsBlink = blinkFunction } where
    blinkFunction duration = for_ [1..duration] . const $ putStrLn "Lights are blinking!"

无需做任何花哨的事情。您可以使用组合和函数来实现您想要的任何行为。

方法 5 - 完全避免多态

这对应于面向对象编程中的“开放与封闭原则”。

有时,正确的做法实际上是完全避免多态性。例如,考虑如何在 Java 中实现单向链表。

abstract class List<T> {
    public abstract bool is_empty();
    public abstract T head();
    public abstract List<T> tail();

    public int length() {
        return empty() ? 0 : 1 + tail().length();
    }
}

class EmptyList<T> {
    @Override
    public bool is_empty() { 
        return true; 
    }

    @Override
    public T head() { 
        throw new IllegalArgumentException("can't take head of empty list"); 
    }

    @Override
    public List<T> tail() { 
        throw new IllegalArgumentException("can't take tail of empty list"); 
    }
}

class NonEmptyList<T> {
    private final T head;
    private final List<T> tail;

    public NonEmptyList(T head,List<T> tail) {
        this.head = head;
        this.tail = tail;
    }

    @Override
    public bool is_empty() { 
        return false; 
    }

    @Override
    public T head() { 
        return self.head; 
    }

    @Override
    public List<T> tail() { 
        return self.tail; 
    }
}

然而,这实际上不是一个好的模型,因为您只希望有两种构造列表的方式——空方式和非空方式。 Haskell 允许你非常简单地做到这一点。类似的 Haskell 代码是

data List t = EmptyList | NonEmptyList t (List t)

empty :: List t -> Bool
empty EmptyList = True
empty (NonEmptyList t listT) = False

head :: List t -> t
head EmptyList = error "can't take head of empty list"
head (NonEmptyList t listT) = t

tail :: List t -> List t
tail EmptyList = error "can't take tail of empty list"
tail (NonEmptyList t listT) = listT

length list = if empty list then 0 else 1 + length (tail list)

当然,在 Haskell 中,我们尽量避免使用“部分”函数——我们尽量确保每个函数总是返回一个值。所以你不会看到很多 Haskeller 正是因为这个原因而实际使用“头”和“尾”函数——他们有时会出错。你会看到由

定义的长度
length EmptyList = 0
length (NonEmptyList t listT) = 1 + length listT

使用模式匹配。

函数式编程语言的这一特性被称为“代数数据类型”。这非常有用。

希望我已经让您确信,函数式编程不仅可以让您实现许多面向对象的设计模式,而且实际上可以让您以更简洁明了的形式表达相同的想法。

,

我在您的示例中添加了一些糖,因为很难用您的函数证明以对象为中心的实现是合理的。

请注意,我没有写很多 Haskell,但我认为这是进行比较的正确语言。

我不建议直接比较纯 OO 语言和纯 FP 语言,因为这是浪费时间。如果您学习 FP 语言并学习如何从功能上进行思考,您将不会错过任何面向对象的功能。

-- We define and create data of type Fish and Bird

data Fish = Fish String
nemo = Fish "Nemo";

data Bird = Bird String
tweety = Bird "Tweety"


-- We define how they can be displayed with the function `show`

instance Show Fish where
    show (Fish name) = name ++ " the fish"

instance Show Bird where
    show (Bird name) = name ++ " the bird"


{- We define how animals can think with the function `think`.
   Both Fish and Bird will be Animals.
   Notice how `show` dispatches to the correct implementation.
   We need to add to the type signature the constraint that
   animals are showable in order to use `show`.
-}

class Show a => Animal a where
    think :: a -> String -> String
    think animal thought =
        show animal ++ " is thinking about " ++ thought

instance Animal Fish
instance Animal Bird


-- Same thing for Swimmer,only with Fish

class Show s => Swimmer s where
    swim :: s -> String -> String
    swim swimmer length =
        show swimmer ++ " is swimming " ++ length

instance Swimmer Fish


-- Same thing for Singer,only with Bird

class Show s => Singer s where
    sing :: s -> String
    sing singer = show singer ++ " is singing"

instance Singer Bird


{- We define a function which applies to any animal.
   The compiler can figure out that it takes any type
   of the class Animal because we are using `think`.
-}

goToCollege animal = think animal "quantum physics"


-- we're printing the values to the console

main = do
    -- prints "Nemo the fish is thinking about quantum physics"
    print $ goToCollege nemo

    -- prints "Nemo the fish is swimming 4 meters"
    print $ swim nemo "4 meters"

    -- prints "Tweety the bird is thinking about quantum physics"
    print $ goToCollege tweety

    -- prints "Tweety the bird is singing"
    print $ sing tweety

我想知道它在 Clojure 中会是什么样子。这并不令人满意,因为 defprotocol 不提供默认实现,但话又说回来:我们不是在不为它设计的语言上强加一种风格吗?

(defprotocol Show
    (show [showable]))

(defprotocol Animal
    (think [animal thought]))

(defn animal-think [animal thought]
    (str (show animal) " is thinking about " thought))

(defprotocol Swimmer
    (swim [swimmer length]))

(defprotocol Singer
    (sing [singer]))

(defrecord Fish [name]
    Show
    (show [fish] (str (:name fish) " the fish"))
    Animal
    (think [a b] (animal-think a b))
    Swimmer
    (swim [swimmer length] (str (show swimmer) " is swimming " length)))

(defrecord Bird [name]
    Show
    (show [fish] (str (:name fish) " the bird"))
    Animal
    (think [a b] (animal-think a b))
    Singer
    (sing [singer] (str (show singer) " is singing")))

(defn goToCollege [animal]
    (think animal "quantum physics"))

(def nemo (Fish. "Nemo"))

(def tweety (Bird. "Tweety"))

(println (goToCollege nemo))
(println (swim nemo "4 meters"))
(println (goToCollege tweety))
(println (sing tweety))

,

问题在于你想要什么样的多态。如果您在编译时只需要一些多态性,Haskell 的 typeclass 几乎适用于大多数情况。

如果您想拥有运行时的多态性(即根据运行时类型动态切换行为),许多函数式编程语言不鼓励这种编程模式,因为使用强大的泛型和类型类,动态多态性并不总是必要的。

简而言之,如果语言支持子类型,你可以选择动态多态,而在没有完整子类型的严格函数式语言中,你应该始终以函数方式编程。最后,如果您仍然想要两者(动态多态性和强大的类型类),您可以尝试具有 traits 的语言,如 ScalaRust。 >