1 public interface IUnit
2 {
3 void ChangeState(UnitStateEnum state);
4 void Patrol();
5 IUnit GetNearestTarget();
6 void LockTarget(IUnit unit);
7 float GetFleeBloodRate();
8 bool CanMove();
9 bool HpRateLessThan(float rate);
10 void Flee();
11 void Speak();
12 }
然後,我們可以通過一個簡單的有限狀態機(FSM)來控制這個單位的行為。不同狀態下,單位都具有不同的行為准則,以形成智能體。
具體來說,我們可以定義這樣幾種狀態:
1 public interface IState<TState, TUnit> where TState : IConvertible
2 {
3 TState Enum { get; }
4 TUnit Self { get; }
5 void OnEnter();
6 void Drive();
7 void OnExit();
8 }
以逃跑狀態為例:
1 public class FleeState : UnitStateBase
2 {
3 public FleeState(IUnit self) : base(UnitStateEnum.Flee, self)
4 {
5 }
6 public override void OnEnter()
7 {
8 Self.Flee();
9 }
10 public override void Drive()
11 {
12 var unit = Self.GetNearestTarget();
13 if (unit != null)
14 {
15 return;
16 }
17
18 Self.ChangeState(UnitStateEnum.Patrol);
19 }
20 }
1 public interface IState<TState, TUnit> where TState : IConvertible
2 {
3 TState Enum { get; }
4 void OnEnter(TUnit self);
5 void Drive(TUnit self);
6 void OnExit(TUnit self);
7 }
還是拿之前實現好的逃跑狀態作為例子:
1 public class FleeState : UnitStateBase
2 {
3 public FleeState() : base(UnitStateEnum.Flee)
4 {
5 }
6 public override void OnEnter(IUnit self)
7 {
8 base.OnEnter(self);
9 self.Flee();
10 }
11 public override void Drive(IUnit self)
12 {
13 base.Drive(self);
14
15 var unit = self.GetNearestTarget();
16 if (unit != null)
17 {
18 return;
19 }
20
21 self.ChangeState(UnitStateEnum.Patrol);
22 }
23 }
這樣,就區分了動態與靜態。靜態的是狀態之間的遷移邏輯,只要不做熱更新,是不會變的結構。動態的是狀態遷移過程中的上下文,根據不同的上下文來決定。
考慮這樣一個組合狀態情景:巡邏時,需要依次得先走到一個點,然後怠工一會兒,再走到下一個點,然後再怠工一會兒,循環往復。這樣就需要父狀態(巡邏狀態)注記當前激活的子狀態,並且根據子狀態執行結果的不同來修改激活的子狀態集合。這樣不僅是Unit自身有上下文,連組合狀態也有了自己的上下文。
為了簡化討論,我們還是從non-ContextFree層次狀態機系統設計開始。
修改後的狀態定義:1 public interface IState<TState, TCleverUnit, TResult>
2 where TState : IConvertible
3 {
4 // ...
5 TResult Drive();
6 // ...
7 }
組合狀態的定義:
1 public abstract class UnitCompositeStateBase : UnitStateBase
2 {
3 protected readonly LinkedList<UnitStateBase> subStates = new LinkedList<UnitStateBase>();
4
5 // ...
6 protected Result ProcessSubStates()
7 {
8 if (subStates.Count == 0)
9 {
10 return Result.Success;
11 }
12
13 var front = subStates.First;
14 var res = front.Value.Drive();
15
16 if (res != Result.Continue)
17 {
18 subStates.RemoveFirst();
19 }
20
21 return Result.Continue;
22 }
23 // ...
24 }
25
巡邏狀態現在是一個組合狀態:
1 public class PatrolState : UnitCompositeStateBase
2 {
3 // ...
4 public override void OnEnter()
5 {
6 base.OnEnter();
7 AddSubState(new MoveToState(Self));
8 }
9
10 public override Result Drive()
11 {
12 if (subStates.Count == 0)
13 {
14 return Result.Success;
15 }
16
17 var unit = Self.GetNearestTarget();
18 if (unit != null)
19 {
20 Self.LockTarget(unit);
21 return Result.Success;
22 }
23
24 var front = subStates.First;
25 var ret = front.Value.Drive();
26
27 if (ret != Result.Continue)
28 {
29 if (front.Value.Enum == CleverUnitStateEnum.MoveTo)
30 {
31 AddSubState(new IdleState(Self));
32 }
33 else
34 {
35 AddSubState(new MoveToState(Self));
36 }
37 }
38
39 return Result.Continue;
40 }
41 }
看過《游戲人工智能編程精粹》的同學可能看到這裡就會發現,這種層次狀態機其實就是這本書裡講的目標驅動的狀態機。組合狀態就是組合目標,子狀態就是子目標。父目標/狀態的調度取決於子目標/狀態的完成情況。這種狀態框架與普通的trivial狀態機模型的區別僅僅是增加了對層次狀態的支持,狀態的遷移還是需要靠顯式的ChangeState來做。
這本書裡面的狀態框架,每個狀態的執行status記錄在了實例內部,不方便後續的優化,我們這裡實現的時候首先把這個做成純驅動式的。但是還不夠。現在之前的ContextFree優化成果已經回退掉了,我們還需要補充回來。
1 public class Continuation
2 {
3 public Continuation SubContinuation { get; set; }
4 public int NextStep { get; set; }
5 public object Param { get; set; }
6 }
7
8 public class Context<T>
9 {
10 public Continuation Continuation { get; set; }
11 public T Self { get; set; }
12 }
修改State的接口定義為:
1 public interface IState<TCleverUnit, TResult>
2 {
3 TResult Drive(Context<TCleverUnit> ctx);
4 }
已經相當簡潔了。
這樣,我們對之前的巡邏狀態也做下修改,達到一個ContextFree的效果。利用Context中的Continuation來確定當前結點應該從什麼狀態繼續:
1 public class PatrolState : IState<ICleverUnit, Result>
2 {
3 private readonly List<IState<ICleverUnit, Result>> subStates;
4 public PatrolState()
5 {
6 subStates = new List<IState<ICleverUnit, Result>>()
7 {
8 new MoveToState(),
9 new IdleState(),
10 };
11 }
12 public Result Drive(Context<ICleverUnit> ctx)
13 {
14 var unit = ctx.Self.GetNearestTarget();
15 if (unit != null)
16 {
17 ctx.Self.LockTarget(unit);
18
19 return Result.Success;
20 }
21
22 var nextStep = 0;
23 if (ctx.Continuation != null)
24 {
25 // Continuation
26 var thisContinuation = ctx.Continuation;
27
28 ctx.Continuation = thisContinuation.SubContinuation;
29
30 var ret = subStates[nextStep].Drive(ctx);
31
32 if (ret == Result.Continue)
33 {
34 thisContinuation.SubContinuation = ctx.Continuation;
35 ctx.Continuation = thisContinuation;
36
37 return Result.Continue;
38 }
39 else if (ret == Result.Failure)
40 {
41 ctx.Continuation = null;
42
43 return Result.Failure;
44 }
45
46 ctx.Continuation = null;
47 nextStep = thisContinuation.NextStep + 1;
48 }
49
50 for (; nextStep < subStates.Count; nextStep++)
51 {
52 var ret = subStates[nextStep].Drive(ctx);
53 if (ret == Result.Continue)
54 {
55 ctx.Continuation = new Continuation()
56 {
57 SubContinuation = ctx.Continuation,
58 NextStep = nextStep,
59 };
60
61 return Result.Continue;
62 }
63 else if (ret == Result.Failure)
64 {
65 ctx.Continuation = null;
66
67 return Result.Failure;
68 }
69 }
70
71 ctx.Continuation = null;
72
73 return Result.Success;
74 }
75 }
subStates是readonly的,在組合狀態構造的一開始就確定了值。這樣結構本身就是靜態的,而上下文是動態的。不同的entity instance共用同一個樹的instance。
1 public interface IO<T>
2 {
3 T Drive(Context ctx);
4 }
這樣,我們之前的所有結點都應該有IO的concept。
之前提出了Parallel、Sequence、Select、Check這樣幾個語義結點。具體的實現細節就不再細說了,簡單列一下代碼結構:
public class Sequence : IO<Result>
{
private readonly ICollection<IO<Result>> subTrees;
public Sequence(ICollection<IO<Result>> subTrees)
{
this.subTrees = subTrees;
}
public Result Drive(Context ctx)
{
throw new NotImplementedException();
}
}
With結點的實現,采用我們之前說的第一種方案:
1 public class With<T, TR> : IO<TR>
2 {
3 // ...
4 public TR Drive(Context ctx)
5 {
6 var thisContinuation = ctx.Continuation;
7 var value = default(T);
8 var skipIoGet = false;
9
10 if (thisContinuation != null)
11 {
12 // Continuation
13 ctx.Continuation = thisContinuation.SubContinuation;
14
15 // 0表示需要繼續ioGet
16 // 1表示需要繼續subTree
17 if (thisContinuation.NextStep == 1)
18 {
19 skipIoGet = true;
20 value = (T) thisContinuation.Param;
21 }
22 }
23
24 if (!skipIoGet)
25 {
26 value = ioGet.Drive(ctx);
27
28 if (ctx.Continuation != null)
29 {
30 // ioGet拋出了Continue
31 if (thisContinuation == null)
32 {
33 thisContinuation = new Continuation()
34 {
35 SubContinuation = ctx.Continuation,
36 NextStep = 0,
37 };
38 }
39 else
40 {
41 thisContinuation.SubContinuation = ctx.Continuation;
42 thisContinuation.NextStep = 0;
43 }
44
45 ctx.Continuation = thisContinuation;
46
47 return default(TR);
48 }
49 }
50
51 var oldValue = box.SetVal(value);
52 var ret = subTree.Drive(ctx);
53
54 box.SetVal(oldValue);
55
56 if (ctx.Continuation != null)
57 {
58 // subTree拋出了Continue
59 if (thisContinuation == null)
60 {
61 thisContinuation = new Continuation()
62 {
63 SubContinuation = ctx.Continuation,
64 };
65 }
66
67 ctx.Continuation = thisContinuation;
68 thisContinuation.Param = value;
69 }
70
71 return ret;
72 }
73 }
這樣,我們的層次狀態機就全部組件化了。我們可以用通用的語義結點來組合出任意的子狀態,這些子狀態是不具名的,對構建過程更友好。
具體的代碼例子:
Par(
Seq(IsFleeing, ((Box<object> a) => With(a, GetNearestTarget, Check(IsNull(a))))(new Box<object>()), Patrol)
,Seq(IsAttacking, ((Box<float> a) => With(a, GetFleeBloodRate, Check(HpRateLessThan(a))))(new Box<float>()))
,Seq(IsNormal, Loop(Par(((Box<object> a) => With(a, GetNearestTarget, Seq(Check(IsNull(a)), LockTarget(a)))(new Box<object>()), Seq(Seq(Check(ReachCurrentPatrolPoint), MoveToNextPatrolPoiont), Idle))))))
看起來似乎是變得復雜了,原來可能只需要一句new XXXState(),現在卻需要自己用代碼拼接出來一個行為邏輯。但是仔細想一下,改成這樣的描述其實對整個工作流是有好處的。之前的形式完全是硬編碼,而現在,似乎讓我們看到了轉數據驅動的可能性。
#region HpRateLessThan
private class MessageHpRateLessThan : IO<bool>
{
public readonly float p0;
public MessageHpRateLessThan(float p0)
{
this.p0 = p0;
}
public bool Drive(Context ctx)
{
return ((T)ctx.Self).HpRateLessThan(p0);
}
}
public static IO<bool> HpRateLessThan(float p0)
{
return new MessageHpRateLessThan(p0);
}
#endregion
經過包裝的行為結點的代碼都是有規律可循的,所以我們可以比較容易的通過一些代碼生成的機制來做。比如通過反射拿到IUnit定義的接口信息,然後直接在這基礎之上做一下包裝,做出來個行為結點的定義。
現在我們再回憶下討論過的With,構造一個葉子結點的時候,參數不一定是literal value,也有可能是經過Box包裹過的。所以就需要對Boax和literal value抽象出來一個公共的概念,葉子結點/行為結點可以從這個概念中拿到值,而行為結點計算本身的構造也只需要依賴於這個概念。
我們把這個概念命名為Thunk。Thunk包裹一個值或者一個box,而就目前來看,這個Thunk,僅需要提供一個我們可以通過其拿到裡面的值的接口就夠了。 public abstract class Thunk<T>
{
public abstract T GetUserValue();
}
對於常量,我們可以構造一個包裹了常量的thunk;而對於box,其天然就屬於Thunk的concept。
這樣,我們就通過一個Thunk的概念,硬生生把樹中的結點與值分割成了兩個概念。這樣做究竟正確不正確呢?
如果一個行為結點的參數可能有的類型本來就是一些primitive type,或者是外部世界(相對於AI世界)的類型,那肯定是沒問題的。但如果需要支持這樣一種特性:外部世界的函數,返回值是AI世界的某個概念,比如一個樹結點;而我的AI世界,希望的是通過這個外部世界的函數,動態的拿到一個結點,再動態的加到我的樹中,或者再動態的傳給不通的外部世界的函數,應該怎麼做?
對於一顆With子樹(Negate表示對子樹結果取反,Continue仍取Continue):
((Box<IO<Result>> a) =>
With(a, GetNearestTarget, Negate(a)))(new Box<IO<Result>>())
語義需要保證,這顆子樹執行到任意時刻,都需要是ContextFree的。
假設IOGet返回的是一個普通的值,確實是沒問題的。
但是因為Box包裹的可能是任意值,例如,假設IOGet返回的是一個IO,
public abstract class IO<T> : Thunk<IO<T>>
{
public abstract T Drive(Context ctx);
public override IO<T> GetUserValue()
{
return this;
}
}
痛點:
(declare
(HpRateLessThan :: (Float -> IO Result))
(GetFleeBloodRate :: Float)
(IsNull :: (Object -> Bool))
(Idle :: IO Result))
(declare
(check :: (Bool -> IO Result))
(loop :: (IO Result -> IO Result))
(par :: (List IO Result -> IO Result)))
因為是以Scheme為主要借鑒對象,所以內建的復雜類型實現上本質是一個ADT,當然,有針對list構造專用的語法糖,但是其parse出來拿到的AST中一個list終究還是一個ADT。
直接拿例子來說比較直觀:
(import Prelude)
(import BaseAI)
(define Root
(par [(seq [(check IsFleeing)
((\a (check (IsNull a))) GetNearestTarget)])
(seq [(check IsAttacking)
((\b (HpRateLessThan b)) GetFleeBloodRate)])
(seq [(check IsNormal)
(loop
(par [((\c (seq [(check (IsNull c))
(LockTarget c)])) GetNearestTarget)
(seq [(seq [(check ReachCurrentPatrolPoint)
MoveToNextPatrolPoiont])
Idle])]))])]))
可以看到,跟S-Expression沒什麼太大的區別,可能lambda的聲明方式變了下。
然後是詞法分析和語法分析,這裡我選擇的是Haskell的ParseC。一些更傳統的選擇可能是lex+yacc/flex+bison。但是這種兩個工具一起混用學習成本就不用說了,也違背了simple is better的初衷。ParseC使用起來就跟PEG是一樣的,PEG這種形式,是天然的結合了正則與top-down parser。haskell支持的algebraic data types,天然就是用來定義AST結構的,簡單直觀。haskell實現的hindly-miner類型系統,又是讓你寫代碼基本編譯通過就能直接run出正確結果,從一定程度上彌補了PEG天生不適合調試的缺陷。一個haskell的庫就能解決lexical&grammar,實在方便。 先是一些AST結構的預定義:module Common where
import qualified Data.Map as Map
type Identifier = String
type ValEnv = Map.Map Identifier Val
type TypeEnv = Map.Map Identifier Type
type DecEnv = Map.Map Identifier (String,Dec)
data Type =
NormalType String
| GenericType String Type
| AppType [Type]
data Dec =
DefineDec Pat Exp
| ImportDec String
| DeclareDec Pat Type
| DeclaresDec [Dec]
data Exp =
ConstExp Val
| VarExp Identifier
| LambdaExp Pat Exp
| AppExp Exp Exp
| ADTExp String [Exp]
data Val =
NilVal
| BoolVal Bool
| IntVal Integer
| FloatVal Float
| StringVal String
data Pat =
VarPat Identifier
我在這裡省去了一些跟這篇文章討論的DSL無關的語言特性,比如Pattern的定義我只保留了VarPat;Value的定義我去掉了ClosureVal,雖然語言本身仍然是支持first class function的。
algebraic data type的一個好處就是清晰易懂,定義起來不過區區二十行,但是我們一看就知道之後輸出的AST會是什麼樣。
haskell的ParseC用起來其實跟PEG是沒有本質區別的,組合子本身是自底向上描述的,而parser也是通過parse小元素的parser來構建parse大元素的parser。
例如,haskell的ParseC庫就有這樣幾個強大的特性:
我們可以先根據這些基本的,封裝出來一些通用combinator。
比如正則規則中的star:
star :: Parser a -> Parser [a]
star p = star_p
where
star_p = try plus_p <|> (return [])
plus_p = (:) <$> p <*> star_p
比如plus:
plus :: Parser a -> Parser [a]
plus p = plus_p
where
star_p = try plus_p <|> (return []) <?> "plus_star_p"
plus_p = (:) <$> p <*> star_p <?> "plus_plus_p"
基於這些,我們可以做組裝出來一個parse lambda-exp的parser(p_seperate是對char、plus這些的組裝,表示形如a,b,c這樣的由特定字符分隔的序列):
p_lambda_exp :: Parser Exp
p_lambda_exp = p_between '(' ')' inner
<?> "p_lambda_exp"
where
inner = make_lambda_exp
<$ char '\\'
<*> p_seperate (p_parse p_pat) ","
<*> p_parse p_exp
make_lambda_exp [] e = (LambdaExp NilPat e)
make_lambda_exp (p:[]) e = (LambdaExp p e)
make_lambda_exp (p:ps) e = (LambdaExp p (make_lambda_exp ps e))
有了所有exp的parser,我們就可以組裝出來一個通用的exp parser:
p_exp :: Parser Exp
p_exp = listplus [p_var_exp, p_const_exp, p_lambda_exp, p_app_exp, p_adt_exp, p_list_exp]
<?> "p_exp"
其中,listplus是一種具有優先級的lookahead:
listplus :: [Parser a] -> Parser a listplus lst = foldr (<|>) mzero (map try lst)對於parser來說,其輸入是源文件其輸出是AST。具體來說,其實就是parse出一個Dec數組,拿到AST,供後續的pipeline消費。 我們之前舉的AI的例子,parse出來的AST大概是這副模樣:
-- Prelude.bh
Right [DeclaresDec [ DeclareDec (VarPat "seq") (AppType [GenericType "List" (GenericType "IO" (NormalType "Result")),GenericType "IO" (NormalType "Result")]) ,DeclareDec (VarPat "check") (AppType [NormalType "Bool",GenericType "IO" (NormalType "Result")])]]
-- BaseAI.bh Right [DeclaresDec [ DeclareDec (VarPat "HpRateLessThan") (AppType [NormalType "Float",GenericType "IO" (NormalType "Result")]) ,DeclareDec (VarPat "Idle") (GenericType "IO" (NormalType "Result"))]]
-- AI00001.bh Right [ ImportDec "Prelude" ,ImportDec "BaseAI" ,DefineDec (VarPat "Root") (AppExp (VarExp "par") (ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsFleeing") ,ADTExp "Cons" [ AppExp (LambdaExp (VarPat "a")(AppExp (VarExp "check") (AppExp (VarExp "IsNull") (VarExp "a")))) (VarExp "GetNearestTarget") ,ConstExp NilVal]]) ,ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsAttacking") ,ADTExp "Cons" [ AppExp (LambdaExp (VarPat "b") (AppExp (VarExp "HpRateLessThan") (VarExp "b"))) (VarExp "GetFleeBloodRate") ,ConstExp NilVal]]) ,ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "IsNormal") ,ADTExp "Cons" [ AppExp (VarExp "loop") (AppExp (VarExp "par") (ADTExp "Cons" [ AppExp (LambdaExp (VarPat "c") (AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (AppExp (VarExp"IsNull") (VarExp "c")) ,ADTExp "Cons" [ AppExp (VarExp "LockTarget") (VarExp "c") ,ConstExp NilVal]]))) (VarExp "GetNearestTarget") ,ADTExp "Cons" [ AppExp (VarExp"seq") (ADTExp "Cons" [ AppExp (VarExp "seq") (ADTExp "Cons" [ AppExp (VarExp "check") (VarExp "ReachCurrentPatrolPoint") ,ADTExp "Cons" [ VarExp "MoveToNextPatrolPoiont" ,ConstExp NilVal]]) ,ADTExp "Cons" [ VarExp "Idle" ,ConstExp NilVal]]) ,ConstExp NilVal]])) ,ConstExp NilVal]]) ,ConstExp NilVal]]]))]
前面兩部分是我把在其他模塊定義的declares,選擇性地拿過來兩條。第三部分是這個人形怪AI的整個的AST。其中嵌套的Cons展開之後就是語言內置的List。
正如我們之前所說,做代碼生成之前需要進行一步類型檢查的工作。類型檢查工具其輸入是AST其輸出是一個檢查結果,同時還可以提供AST中的一些輔助信息,包括各標識符的類型信息等等。 類型檢查其實主要的邏輯在於處理Appliacative Type,這中間還有個類型推導的邏輯。形如(\a (Func a)) 10,AST中並不記錄a的type,我們的DSL也不需要支持concept、typeclass等有關type、subtype的復雜機制,推導的時候只需要著重處理AppExp,把右邊表達式的類型求出,合並一下env傳給左邊表達式遞歸檢查即可。 這部分的代碼:exp_type :: Exp -> TypeEnv -> Maybe Type
exp_type (AppExp lexp aexp) env =
(exp_type aexp env) >>= (\at ->
case lexp of
LambdaExp (VarPat var) exp -> (merge_type_env (Just env) (make_type_env var (Just at))) >>= (\env1 -> exp_type lexp env1)
_ -> (exp_type lexp env) >>= (\ltype -> check_type ltype at))
where
check_type (AppType (t1:(t2:[]))) at =
if t1 == at then (Just t2) else Nothing
check_type (AppType (t:ts)) at =
if t == at then (Just (AppType ts)) else Nothing
此外,還需要有一個通用的CodeGenerator模塊,其輸入也是AST,其輸出是另一些AST中的輔助信息,主要是注記下各標識符的import源以及具體的define內容,用來方便各目標語言CodeGenerator直接復用邏輯。
目標語言的CodeGenerator目前只做了C#的。
目標代碼生成的邏輯就比較簡單了,畢竟該有的信息前面的各模塊都提供了,這裡根據之前一個版本的runtime,代碼生成的大致樣子:
public static IO<Result> Root =
Prelude.par(Help.MakeList(
Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsFleeing)
,(((Box<Object> a) => Help.With(a, BaseAI.GetNearestTarget, Prelude.check(BaseAI.IsNull())))(new Box<Object>()))))
,Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsAttacking)
,(((Box<Float> b) => Help.With(b, BaseAI.GetFleeBloodRate, BaseAI.HpRateLessThan()))(new Box<Float>()))))
,Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsNormal)
,Prelude.loop(Prelude.par(Help.MakeList(
(((Box<Object> c) => Help.With(c, BaseAI.GetNearestTarget, Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.IsNull())
,BaseAI.LockTarget()))))(new Box<Object>()))
,Prelude.seq(Help.MakeList(
Prelude.seq(Help.MakeList(
Prelude.check(BaseAI.ReachCurrentPatrolPoint)
,BaseAI.MoveToNextPatrolPoiont))
,BaseAI.Idle)))))))))
總的來說,大致分為這幾個模塊:Parser、TypeChecker、CodeGenerator、目標語言的CodeGenerator。再加上目標語言的runtime,基本上就可以組成這個DSL的全部了。
上面列出來的代碼風格比較混搭,畢竟是前後差的時間比較久了。。parser部分大概是7月左右完成的,那時候喜歡applicative的風格,大量用了<$> <*>;後面的TypeChecker和CodeGenerator都是最近寫的,寫monad expression的時候,Maybe Monad我比較傾向於寫原生的>>=調用,IO Monad如果這樣寫就煩了,所以比較多的用了do-notaion。優化什麼的由於時間原因還沒看RWH的後面幾章,而且DSL的compiler對性能需求的優先級其實很低了,所以暫時沒有考慮過,各位看官將就一下。
((Box<float> a) => (Help.With(a, UnitAI.GetFleeBloodRate, Math.Plus(a, 0.1)))(new Box<float>())這裡Math.Plus屬於這門DSL標准庫的一部分,實現上我們就對底層數學函數做一層簡單的wrapper。但是這樣由於C#語言是pass-by-value,我們在構造這顆With的時候,Math.Plus(a, 0.1)已經求值。但是這個時候Box的值還沒有被填充,求出來肯定是有問題的。 所以我們需要對這樣一種計算再進行一次抽象。希望可以得到的效果是,對於Math.Plus(0.1, 0.2),可以在構造樹的時候直接求值;對於Math.Plus(0.1, a),可以得到某種計算,在我們需要的時候再求值。 先明確下函數調用有哪幾種情況:
public static Thunk<float> Plus(Thunk<float> a, Thunk<float> b)
{
return Help.MakePureThunk(a.GetUserValue() + b.GetUserValue());
}
如果a和b都是literal value,那就沒問題,但是如果有一個是被box包裹的,那就很顯然是有問題的。
所以需要對Thunk這個概念做一下擴展,使之能區別出動態的值與靜態的值。一般情況下的值,都是pure的;box包裹的值,是impure的。同時,這個pure的性質具有值傳遞性,如果這個值屬於另一個值的一部分,那麼這個整體的pure性質與值的局部的pure性質是一致的。這裡特指的值,包括List與IO。
整體的概念我們應該拿haskell中的impure monad做類比,比如haskell中的IO。haskell中的IO依賴於OS的輸入,所以任何返回IO monad的函數都具有傳染性,引用到的函數一定還會被包裹在IO monad之中。
所以,對於With這種情況的傳遞,應該具有這樣的特征:
所以With結點構造的時候,計算pure應該特殊處理一下。但是這個特殊處理的代碼污染性比較大,我在本文就不列出了,只是這樣提一下。
有了pure與impure的標記,我們在對函數調用的時候,就需要額外走一層。
本來一個普通的函數調用,比如UnitAI.Func(p0, p1, p2)與Math.Plus(p0, p1)。前者返回一種computing是毫無疑問的,後者就需要根據參數的類型來決定是返回一種計算還是直接的值。
為了避免在這個Plus裡面改來改去,我們把Closure這個概念給抽象出來。同時,為了簡化討論,我們只列舉T0 -> TR這一種情況,對應的標准庫函數取Abs。
public class Closure<T0, TR> : Thunk<Closure<T0, TR>>
{
class UserFuncApply : Thunk<TR>
{
private Closure<T0, TR> func;
private Thunk<T0> p0;
public UserFuncApply(Closure<T0, TR> func, Thunk<T0> p0)
{
this.func = func;
this.p0 = p0;
this.pure = false;
}
public override TR GetUserValue()
{
return func.funcThunk(p0).GetUserValue();
}
}
private bool isUserFunc = false;
private FuncThunk<T0, TR> funcThunk;
private Func<T0, TR> userFunc;
public Closure(FuncThunk<T0, TR> funcThunk)
{
this.funcThunk = funcThunk;
}
public Closure(Func<T0, TR> func)
{
this.userFunc = func;
this.funcThunk = p0 => Help.MakePureThunk(userFunc(p0.GetUserValue()));
this.isUserFunc = true;
}
public override Closure<T0, TR> GetUserValue()
{
return this;
}
public Thunk<TR> Apply(Thunk<T0> p0)
{
if (!isUserFunc || Help.AllPure(p0))
{
return funcThunk(p0);
}
return new UserFuncApply(this, p0);
}
}
其中,UserFuncApply就是之前所說的一層計算的概念。UserFunc表示的是等效於可以編譯期計算的一種標准庫函數。
這樣定義:
public static class Math
{
public static readonly Thunk<Closure<float, float>> Abs = Help.MakeUserFuncThunk<float,float>(System.Math.Abs);
}
Message類型的Closure構造,都走FuncThunk構造函數;普通函數類型的構造,走Func構造函數,並且包裝一層。
Help.Apply是為了方便做代碼生成,描述一種declarative的Application。其實就是直接調用Closure的Apply。
考慮以下幾種case:
public void Test()
{
var box1 = new Box<float>();
// Math.Abs(box1) -> UserFuncApply
// 在GetUserValue的時候才會求值
var ret1 = Help.Apply(Math.Abs, box1);
// Math.Abs(0.2f) -> Thunk<float>
// 直接構造出來了一個Thunk<float>(0.2f)
var ret2 = Help.Apply(Math.Abs, Help.MakePureThunk(0.2f));
// UnitAISets<IUnit>.HpRateLessThan(box1) -> Message
var ret3 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, box1);
// UnitAISets<IUnit>.HpRateLessThan(0.2f) -> Message
var ret4 = Help.Apply(UnitAISets<IUnit>.HpRateLessThan, Help.MakePureThunk(0.2f));
}
與之前的runtime版本唯一表現上有區別的地方在於,對於純pure參數的userFunc,在Apply完之後會直接計算出來值,並重新包裝成一個Thunk;而對於參數中有impure的情況,返回一個UserFuncApply,在GetUserValue的時候才會求值。