Haskell Any vs C++ Varaint

Java的Class文件中,有一个重要的符号引用列表,即常量池列表。常量池列表中包含各种常量项,在JVM21规范中共有17种常量项。比如:CONSTANT_Class、CONSTANT_Integer、CONSTANT_Utf8等。那么如何使用Haskell表示常量项?

代数类型 (直观的方式)

常量池数据建模

使用Haskell对常量项建模,我们很容易想到使用sum type。CPEntry表示常量项类型,它的各个数据构造器(data constructor)对应不同的常量项值。

data CPEntry
  = Constant_Invalid
  | Constant_Utf8 ConstUtf8
  | Constant_Integer ConstInteger
  | Constant_Float ConstFloat
  | Constant_Long ConstLong
  | Constant_Double ConstDouble
  | Constant_Class ConstClass
  | Constant_String ConstString
  | Constant_Fieldref ConstFieldref
  | Constant_Methodref ConstMethodref
  | Constant_InterfaceMethodref ConstInterfaceMethodref
  | Constant_NameAndType ConstNameAndType
  | Constant_MethodHandle ConstMethodHandle
  | Constant_MethodType ConstMethodType
  | Constant_Dynamic ConstDynamic
  | Constant_InvokeDynamic ConstInvokeDynamic
  | Constant_Module ConstModule
  | Constant_Package ConstPackage
  deriving (Typeable, Show)

相应的,常量池就是常量项的列表。

type CPInfo = [CPEntry]

从常量池中获取常量项

从Class文件解析出常量池后,我们不时需要从某个索引下获取某个常量项。因为该索引下的常量项是任意的,所以就有可能与你想要的不匹配。比如你想获取一个CONSTANT_Utf8,可以使用模式匹配,如果类型一致返回Just,不一致返回Nothing。

getConstUtf8 :: CPEntry -> Maybe ConstUtf8
getConstUtf8 (Constant_Utf8 x) = Just x 
getConstUtf8 _ = Nothing 

想要获取ConstClass?同样的:

getConstClass :: CPEntry -> Maybe ConstClass
getConstClass (Constant_Class x) = Just x 
getConstClass _ = Nothing

后面还有15个不同的常量项需要这样定义。显然,这样做很繁琐,但是可行的(It works)。

使用Dynamic(To the rescue)

首先使用toDyn将CPEntry转为Dynamic。

toDyn :: Typeable a => a -> Dynamic
cpEntryDyn :: CPEntry -> Dynamic
cpEntryDyn info =
   case info of
    Constant_Invalid -> toDyn ()
    Constant_Utf8 x ->  toDyn x
    Constant_Integer x ->  toDyn x
    Constant_Float x ->  toDyn x
    Constant_Long x ->  toDyn x
    Constant_Double x ->  toDyn x
    Constant_Class x ->  toDyn x
    Constant_String x ->  toDyn x
    Constant_Fieldref x ->  toDyn x
    Constant_Methodref x ->  toDyn x
    Constant_InterfaceMethodref x ->  toDyn x
    Constant_NameAndType x ->  toDyn x
    Constant_MethodHandle x ->  toDyn x
    Constant_MethodType x ->  toDyn x
    Constant_Dynamic x ->  toDyn x
    Constant_InvokeDynamic x ->  toDyn x
    Constant_Module x ->  toDyn x
    Constant_Package x ->  toDyn x

再使用fromDynamic将Dynamic对象转化为你想要的对象(通过type signature指定)。因为实际的和你想要的对象可能不一致,所以fromDynamic的返回值也是Maybe。

fromDynamic :: forall a. Typeable a => Dynamic -> Maybe a     

利用上面的两个函数,我们可以定义一个通用的的getCPEntry函数。

getCPEntry :: (Typeable a) => CPEntry -> Maybe a
getCPEntry = fromDynamic . cpEntryDyn

这样只要指定类型,就可以分别定义出getConstUtf8getConstClass

getConstUtf8 = getCPEntry :: Maybe ConstUtf8
getConstClass = getCPEntry :: Maybe ConstClass

但一般情况,我们不再需要定义getConstUtf8等。类型可以通过上下文自动推导出来。比如获取ConstUtf8的文本值。

newtype ConstUtf8 = ConstUtf8 T.Text deriving (Show)

getUtf8Value :: CPInfo -> Maybe T.Text 
getUtf8Value entry = do 
  ConstUtf8 x <- getCPEntry entry
  return x

定义多态方法(Polymorphic)

上述cpEntryDyn方法其实就是一个多态方法。通过模式匹配将各个常量项转为Dynamic。
如果我们想将CPEntry转为不同的Tag值,可以这样定义:

tagCPEntry :: CPEntry -> CPTag
tagCPEntry Constant_Invalid -> JVM_Constant_Invalid
tagCPEntry (Constant_Utf8 _) ->  JVM_Constant_Utf8
tagCPEntry (Constant_Integer _) ->  JVM_Constant_Integer
...

或者转为pretty的Doc类型,以便格式化输出:

pprCPEntry :: CPInfo -> CPReader Doc
pprCPEntry Constant_Invalid = return PP.empty
pprCPEntry (Constant_Utf8 x) = ppr x
pprCPEntry (Constant_Integer x) = ppr x
...

扩展性问题

如果JVM规范后面又新增了一种常量项,比如Constant_Magic,那么所有这些使用了模式匹配的地方都需要修改。否则会有编辑器提示Pattern match(es) are non-exhaustive。这种提示是友好的,因为缺失的模式匹配往往意味着业务BUG,我们甚至可以添加编译参数,使编译器在这个情况下报错。

如果业务变化不大,这种方式也许还不错。但也可以尝试另外一种常量项建模方式...

Existential Types(Another way...)

Algebraic data type、Abstract data type和Generalized Algebraic Type

Algebraic data type指代数数据类型。这里的代数指代数和(sum)或代码乘(product)。也就是说数据类型可以像代数一样做“加法“或“乘法“。代数和表示选择,代数乘表示组合。比如:
data Fruit = Apple | Banana | OtherFruit。水果表示苹果或者香蕉或其他水果。
data Point = P Int Int。点由x坐标和y坐标一起组成。 我们称FruitPoint为类型构造器(type constructor),AppleBananaOtherFruitP为数据构造器(data constructor)。

GADT可以使我们在定义数据类型时,将数据构造器的类型明确的写出来。如使用GADT重新定义的Point如下:

data Point where 
  P :: Int -> Int -> Point

Abstract data type指抽象数据类型,Haskell需要通过module的机制实现。用于隐藏数据的具体实现,只提供操作数据的方法。

Any(Like Java Object)

Haskell列表是同质的,也就是说列表中的元素类型要相同。对于17种常量项,数据类型各不相同,我们没法将他们一并放入某个列表中,所以就需要上述的CPEntry类型封装成统一类型,然后通过模式匹配解构出CPEntry中的实际的常量项。

-- error: 
-- Couldn't match expected type ‘ConstUtf8’
--              with actual type ‘ConstInteger’
entries = [ConstUtf8 "123", ConstInteger 32]

启动语言扩展后可以定义一个类似Java Object的类型Any, 它可以封装任意类型。
ExistentialQuantification使得我们可以在等号的右边定义类型变量。我们称a为existential type variable。

{-# LANGUAGE ExistentialQuantification #-}
data Any = forall a. Any a 

这样我们就可以定义一个异构的列表了。

entries :: [Any]
entries = [Any $ ConstUtf8 "hello", Any $ ConstInteger 123]

虽然我们很容易的定义了entries,但是无法从中得到任何信息,因为类型构造器Any中没有包含a的任何信息。我们要如何获取Any中的数据呢?

解构Any

使用CPS(Continuation Pass Style)解构Any,可以定义如下elimAny方法。注意forall a.的范围。elimAny的完整类型可以写为elimAny::(forall r. (forall a. (a -> r)) -> Any -> r)r的范围包含整个表达式,表示r的类型是由调用方(caller)决定的;a的范围只局限在f函数中,并没有包含在返回值中逃逸出去,表示a的类型是由被调方(callee)决定的。因为(Any x)中的x是任意类型,所以调用方不能决定传入的f函数的入参a是什么类型,只能由被调方决定。

{-# LANGUAGE RankNTypes #-}
elimAny :: (forall a. (a -> r)) -> Any -> r 
elimAny f (Any x) = f x 

有了elimAny我们就可以定义各种各样的f函数,来窥探Any对象的值了。那么什么样的f函数可以接受任意的值a呢?那当然是可以接受任意值的函数了,比如justHello,输入任意值都会返回Hello。

justHello :: a -> String 
justHello _ = "Hello"

使用上述entries测试下。It works!

ghci> justHello $ head entries 
"Hello"

那传入一个Int返回Hello的justIntHello可以作为f函数吗?

justIntHello :: Int -> String 
justIntHello _ = "Hello"

当然是不行的,因为f的入参不能由调用方限定死,我们没法保证(Any x)里的x就是Int类型。

<interactive>:38:21: error:
    • Couldn't match type ‘Any’ with ‘Int’
      Expected: [Int]
        Actual: [Any]
    • In the first argument of ‘head’, namely ‘entries’
      In the second argument of ‘($)’, namely ‘head entries’
      In the expression: justIntHello $ head entries

限定Any的类型

如果只能使用justHello函数,那对于了解对象(Any a)中的数据是意义不大的。我们可以使用type class来限定a的类型, 这样就可以使用更加确切的f函数来应用Any。
比如为Any增加Show的约束,修改Any和elimAny如下:

data Any = forall a. (Show a) => Any a 

elimAny :: (forall a. (Show a) => (a -> r)) -> Any -> r
elimAny f (Any a) = f a 

定义函数showAny。

showAny :: Any -> String 
showAny = elimAny show  

这样就可以在控制台展示Any的内容。

ghci> showAny $ head entries 
"ConstUtf8 \"hello\""
ghci> showAny $ last entries 
"ConstInteger 123"

Dynamic Type (Again)

在前面的展示中,我们使用toDyn和fromDynamic函数,定义了一个通用的函数来解构CPEntry中的常量项。

toDyn :: Typeable a => a -> Dynamic
fromDynamic :: forall a. Typeable a => Dynamic -> Maybe a     

这两个函数都只需要一个Typeable的约束,就可以实现类型的转化。那我们能够给(Any a)的a加上Typeable的约束,以便随后将a转化为任意类型?
再次修改Any和elimAny如下:

data Any = forall a. (Show a, Typeable a) => Any a 

elimAny :: (forall a. (Show a, Typeable a) => (a -> r)) -> Any -> r
elimAny f (Any a) = f a 

如同showAny定义castAny。

castAny :: (Typeable a) => Any -> Maybe a 
castAny  = elimAny cast

测试下castAny。

ghci> castAny $ head entries :: Maybe ConstUtf8 
Just (ConstUtf8 "hello")
ghci> castAny $ last entries :: Maybe ConstInteger
Just (ConstInteger 123)
ghci> castAny $ last entries :: Maybe ConstUtf8 
Nothing

利用class类型定义常量池的接口

Haskell的class是可以继承的,我们可以定义一个ICPEntry class,以及与之对应CPEntryAny。所有实现了ICPEntry的常量项,都可以构造成一个CPEntryAny对象。而任意一个CPEntryAny对象即可以自由的使用ICPEntry中的所有方法,又可以通过castAny转为特定的常量项。例如:

class (Typeable a, HasCPEntryTag a ) => HasCPEntry a
instance HasCPEntry ConstUtf8
instance HasCPEntry ConstInteger

class HasCPEntryTag a where 
    cpEntryTag :: a -> CPTag
instance HasCPEntryTag ConstUtf8 where 
  cpEntryTag _ = JVM_Constant_Utf8
instance HasCPEntryTag ConstInteger where
  cpEntryTag _ = JVM_Constant_Integer  

data CPAny = forall a. (HasCPEntry a) => CPAny a 

elimCPAny :: (forall a. (HasCPEntry a) => (a -> r)) -> CPAny -> r
elimCPAny f (CPAny a) = f a 

castCPAny :: (Typeable a) => CPAny -> Maybe a 
castCPAny  = elimCPAny cast

tagCPAny :: CPAny -> CPTag
tagCPAny = elimCPAny cpEntryTag

此时的CPAny隐藏了具体的常量项实现,只提供了操作这些常量项的接口,是否为一种抽象数据类型?

模式匹配呢?

我们定义了CPAny,也通过class ICPEntry定义了它的接口。但如果我想简单的在一个函数里处理下CPAny,又不想在ICPEntry中增加接口,该怎么办呢?简单而言,能否通过pattern matching的方式解构CPAny?
既然CPAny有ICPEntry的约束,而实现ICPEntry的类型只有ConstUtf8和ConstInteger,所以尝试如下:

-- error
adhocCPAny :: CPAny -> String 
adhocCPAny (CPAny (ConstUtf8 _)) = "U8"
adhocCPAny (CPAny (ConstInteger _)) = "Int"

以上代码会报错,提示类型不匹配。所以,再增加一点点改变...

Existential Types with GADTS(Another way again...)

使用GADTs表示常量项