代码改变世界

适合C# Actor的消息执行方式(2):C# Actor的尴尬

2009-07-13 00:24  Jeffrey Zhao  阅读(...)  评论(... 编辑 收藏

上一篇文章中,我们简单解读了Erlang在执行消息时候的方式。而现在,我们就一起来看看,C# Actor究竟出现了什么样的尴尬。此外,我还打算用F#进行补充说明,最终我们会发现,虽然F#看上去很美,但是在实际使用过程中依旧有些遗憾。

Erlang中的Tag Message

老赵在上一篇文章里提到,Erlang中有一个“约定俗成”,使用“原子(atom)”来表示这条消息“做什么”,并使用“绑定(binding)”来获取做事情所需要的“参数”。Erlang大拿,《Programming Erlang》一书的主要译者jackyz同学看了老赵的文章后指出,这一点在Erlang编程规范中有着明确的说法,是为“Tag Message”:

5.7 Tag messages

All messages should be tagged. This makes the order in the receive statement less important and the implementation of new messages easier.

Don’t program like this:

loop(State) ->
 
receive
    ...
    {
Mod, Funcs, Args} -> % Don't do this
     
apply(Mod, Funcs, Args},
     
loop(State);
    ...
 
end.

The new message {get_status_info, From, Option} will introduce a conflict if it is placed below the {Mod, Func, Args} message.

If messages are synchronous, the return message should be tagged with a new atom, describing the returned message. Example: if the incoming message is tagged get_status_info, the returned message could be tagged status_info. One reason for choosing different tags is to make debugging easier.

This is a good solution:

loop(State) ->
 
receive
    ...
    {
execute, Mod, Funcs, Args} -> % Use a tagged message.
     
apply(Mod, Funcs, Args},
     
loop(State);
    {
get_status_info, From, Option} ->
     
From ! {status_info, get_status_info(Option, State)},
     
loop(State);   
    ...
 
end.

第一段代码使用的模式为拥有三个“绑定”的“元组”。由于Erlang的弱类型特性,任何拥有三个元素的元组都会被匹配到,这不是一个优秀的实践。在第二个示例中,每个模式使用一个“原子”来进行约束,这样可以获取到相对具体的消息。为什么说“相对”?还是因为Erlang的弱类型特性,Erlang无法对From和Option提出更多的描述。同样它也无法得知execute或get_status_info这两个tag的来源——当然,在许多时候,它也不需要关心是谁发送给它的。

在C#中使用Tag Message

在C#中模拟Erlang里的Tag Message很简单,其实就是把每条消息封装为Tag和参数列表的形式。同样的,我们使用的都是弱类型的数据——也就是object类型。如下:

public class Message
{
    public object Tag { get; private set; }

    public ReadOnlyCollection<object> Arguments { get; private set; }

    public Message(object tag, params object[] arguments)
    {
        this.Tag = tag;
        this.Arguments = new ReadOnlyCollection<object>(arguments);
    }
}

我们可以使用这种方式来实现一个乒乓测试。既然是Tag Message,那么定义一些Tag便是首要任务。Tag表示“做什么”,即消息的“功能”。在乒乓测试中,有两种消息,共三个“含义”。Erlang使用原子作为tag,在.NET中我们自然可以使用枚举:

public enum PingMsg
{ 
    Finished,
    Ping
}

public enum PongMsg
{ 
    Pong
}

在这里,我们使用简单的ActorLite进行演示(请参考ActorLite的使用方式)。因此,Ping和Pong均继承于Actor<Message>类,并实现其Receive方法。

对于Ping对象来说,它会维护一个计数器。每当收到PongMsg.Pong消息后,会将计数器减1。如果计数器为0,则回复一条PingMsg.Finished消息,否则就回复一个PingMsg.Ping:

public class Ping : Actor<Message>
{
    private int m_count;

    public Ping(int count)
    {
        this.m_count = count;
    }

    public void Start(Actor<Message> pong)
    {
        pong.Post(new Message(PingMsg.Ping, this));
    }

    protected override void Receive(Message message)
    {
        if (message.Tag.Equals(PongMsg.Pong))
        {
            Console.WriteLine("Ping received pong");

            var pong = message.Arguments[0] as Actor<Message>;
            if (--this.m_count > 0)
            {
                pong.Post(new Message(PingMsg.Ping, this));
            }
            else
            {
                pong.Post(new Message(PingMsg.Finished));
                this.Exit();
            }
        }
    }
}

对于Pong对象来说,如果接受到PingMsg.Ping消息,则回复一个PongMsg.Pong。如果接受的消息为PingMsg.Finished,便立即退出:

public class Pong : Actor<Message>
{
    protected override void Receive(Message message)
    {
        if (message.Tag.Equals(PingMsg.Ping))
        {
            Console.WriteLine("Pong received ping");

            var ping = message.Arguments[0] as Actor<Message>;
            ping.Post(new Message(PongMsg.Pong, this));
        }
        else if (message.Tag.Equals(PingMsg.Finished))
        {
            Console.WriteLine("Finished");
            this.Exit();
        }
    }
}

启动乒乓测试:

new Ping(5).Start(new Pong());

结果如下:

Pong received ping
Ping received pong
Pong received ping
Ping received pong
Pong received ping
Ping received pong
Pong received ping
Ping received pong
Pong received ping
Ping received pong
Finished

从上述代码中可以看出,由于没有Erlang的模式匹配,我们必须使用if…else…的方式来判断消息的Tag,接下来还必须使用麻烦而危险的cast操作来获取参数。更令人尴尬的是,与Erlang相比,在C#中使用Tag Message没有获得任何好处。同样是弱类型,同样得不到静态检查。那么好处在哪里?至少我的确看不出来。

有朋友可能会说,C#既然是一门强类型的语言,为什么要学Erlang的Tag Message?为什么不把Ping定义为Actor<PingMessage>,同时把Pong定义为Actor<PingMessage>呢?

呃……我承认,在这里使用Tag Message的确有种“画虎不成反类犬”的味道。不过,事情也不是您想象的那么简单。因为在实际情况中,一个Actor可能与各种外部服务打交道,它会接受到各式各样的消息。例如,它先向Service Locator发送一个请求,用于查询数据服务的位置,这样它会接受到一个ServiceLocatorResponse消息。然后,它会向数据服务发送一个请求,再接受到一个DataAccessResponse消息。也就是说,很可能我们必须把每个Actor都定义为Actor<object>,然后对消息进行类型判断,转换,再加以处理。

诚然,这种方法相对于Tag Message拥有了一定的强类型优势(如静态检查)。但是如果您选择这么做,就必须为各种消息定义不同的类型,在这方面会带来额外的开发成本。要知道,消息的数量并不等于Actor类型的数量,即使是如Ping这样简单的Actor,都会发送两种不同的消息(Ping和Finished),而且每种消息拥有各自的参数。一般来说,某个Actor会接受2-3种消息都是比较正常的状况。在面对消息类型的汪洋时,您可能就会怀念Tag Message这种做法了。到时候您可能就会发牢骚说:

“弱类型就弱类型吧,Erlang不也用的好好的么……”

F#中的模式匹配

提到模式匹配,熟悉F#的同学们可能会欢喜不已。模式匹配是F#中的重要特性,它将F#中静态类型系统的灵活性体现地淋漓尽致。而且——它还很能节省代码(这点在老赵以前的文章中也有所提及)。那么我们再来看一次F#在乒乓测试中的表现。

首先还是定义PingMsg和PongMsg:

type PingMsg = 
    | Ping of PongMsg Actor
    | Finished
and PongMsg = 
    | Pong of PingMsg Actor

这里体现了F#类型系统中的Discriminated Unions。简单地说,它的作用是把一种类型定义为多种表现形式,这个特性在Haskell等编程语言中非常常见。Discriminated Unions非常适合模式匹配,现在的ping对象和pong对象便可定义如下(在这里还是使用了ActorLite,而不是F#标准库中的MailboxProcessor来实现Actor模型):

let (<<) (a:_ Actor) msg = a.Post msg

let ping =
    let count = ref 5
    { new PongMsg Actor() with
        override self.Receive(message) =
            match message with
            | Pong(pong) ->
                printfn "Ping received pong"
                count := !count - 1
                if (!count > 0) then
                    pong << Ping(self)
                else
                    pong << Finished
                    self.Exit() }

let pong = 
    { new PingMsg Actor() with
        override self.Receive(message) =
            match message with
            | Ping(ping) ->
                printfn "Pong received ping"
                ping << Pong(self)
            | Finished ->
                printf "Fininshed"
                self.Exit() }

例如在pong对象的实现中,我们使用模式匹配,减少了不必要的类型转换和赋值,让代码变得简洁易读。还有一点值得顺带一提,我们在F#中可以灵活的定义一个操作符的作用,在这里我们便把“<<”定义为“发送”操作,避免Post方法的显式调用。这种做法往往可以简化代码,从语义上增强了代码的可读性。例如,我们可以这样启动乒乓测试:

ping << Pong(pong)

至于结果则与C#的例子一模一样,就不再重复了。

F#中的弱类型消息

可是,F#的世界就真的如此美好吗?试想,我们该如何实现一个需要接受多种不同消息的Actor对象呢?我们只能这样做:

let another = 
    { new obj Actor() with
        override self.Receive(message) =
            match message with
            
            | :? PingMsg as pingMsg ->
                // sub matching
                match pingMsg with
                | Ping(pong) -> null |> ignore
                | Finished -> null |> ignore
                
            | :? PongMsg as pongMsg ->
                // sub matching
                match pongMsg with
                | Pong(ping) -> null |> ignore
                
            | :? (string * int) as m ->
                // sub binding
                let (s, i) = m
                null |> ignore
                
            | _ -> failwith "Unrecognized message" }

由于我们必须使用object作为Actor接受到的消息类型,因此我们在对它作模式匹配时,只能进行参数判断。如果您要更进一步地“挖掘”其中的数据,则很可能需要进行再一次的模式匹配(如PingMsg或PongMsg)或赋值(如string * int元组)。一旦出现这种情况,在我看来也变得不是那么理想了,我们既没有节省代码,也没有让代码变得更为易读。与C#相比,唯一的优势可能就是F#中相对灵活的类型系统吧。

C#不好用,F#也不行……那么我们又该怎么办?

相关文章