这是 senioria 这几天重构 blog generator 时产生的需求: 一个任务可能有很多步骤, 不同的步骤可能是并发的, 而串行执行的步骤之间需要相互交换数据, 甚至相互注入逻辑; 需求是使得在源代码中添加, 删除和配置这些步骤尽量简单.
(senioria 承认这样的需求对于 blog generator 来说可能有点可怕了… 原因是, profile 发现 pandoc 和 agda 编译文档的时间是大头, 并且这一时间开销是不可减少的, 那么就只能通过尽量增加并行数量来加速了… (超小声(
并发本身还没有想到什么解决方案… 对于添加步骤的管理, senioria 想到的是 heterogeneous list: 用一个 list 容纳所有步骤对应的类型, 这样, 步骤之间相互访问就是类型安全的了, 也可以提供方便的 api 来遍历步骤, 而添加一个新的步骤也只需要编写它的实现, 和在步骤列表里添加它对应的类型就好.
大概这样的需求本身问题就不小, 不过既然做都做了 x (超小声(
这里是 senioria 糊出来的几种方案:
js
首先从最简单的讲起 x (超小声(
js 中的 list 天然就不必是同构的, 而且也支持在运行时动态拓展, 所以只需要把对应的工厂函数放进 list 里就好,
步骤之间相互访问也是自然的:
let Steps = [];
function work() {
step_list = Steps.map((f) => f());
for (const step of step_list) step.work(step_list);
}
class StepA {
count;
constructor() {
this.count = 0;
}
work(slist) {
console.log(`Step A, count = ${this.count}, name = ${slist[1].name}`);
}
}
Steps.push(() => new StepA());
class StepB {
name;
constructor() {
this.name = "qwq";
}
work(slist) {
console.log(`Step B, count = ${slist[0].count}, name = ${this.name}`);
}
}
Steps.push(() => new StepB());
work();
很明显, 这样的做法并不类型安全: 我们并不知道是否存在这么一个步骤, 也不知道这个步骤是否有这个数据, 一切都依靠约定来完成. 这也是纯js项目的通病了.
如果加上点动态技巧, 并且使用对象名字来索引的话, js的这一解决方案就全程是access by name的了.
改成ts的话, 类型标注的手段和c++是相似的, 这些我们将在下文中描述.
idris2
idris2的操作就要复杂得多, 主要是因为各种类型之间有复杂的相互依赖关系, 虽然可能也是因为idris2的类型推导能力还有待提高 x (超小声(
可惜的是, 隔壁类型系统更强大, 类型推导能力也更强的agda, 因为IO Monad只能固定在一个Level上, 所以senioria还没想到可行的实现这种业务流程的思路.
这里senioria直接把整个流程写成了静态的, 把写死的步骤列表改成参数的话, 这个思路应该也能用来实现动态的类型列表.
由于类型检查系统的限制, 这里步骤列表的声明, 每个步骤的数据类型的声明, 和每个步骤工作的函数的定义必须分开…
module Test
import Data.List.Elem
import Data.List.Quantifiers
(.get) : HList ts -> (t : Type) -> {auto inlist : Elem t ts} -> t
(.get) (car :: cdr) t {inlist = Here} = car
(.get) (car :: cdr) t {inlist = (There _)} = cdr.get t
(.set) : HList ts -> (t : Type) -> {auto inlist : Elem t ts} -> t -> HList ts
(.set) (car :: cdr) t {inlist = Here} val = val :: cdr
(.set) (car :: cdr) t {inlist = (There x)} val = car :: cdr.set t val
mutual
record StepData where
constructor MkStepData
infoT : Type
geninfo : forall io . HasIO io => io infoT
work : forall io . HasIO io => HList Test.step_list_types -> io (HList Test.step_list_types)
step_list : List StepData
step_list_types : List Type
step_list_types = map (.infoT) step_list
work : HasIO io => io (HList Test.step_list_types)
work = do
(infos, workers) <- make_info step_list
do_work infos workers
where
make_info : (steps : List StepData)
-> io (HList $ map (.infoT) steps,
List $ HList Test.step_list_types -> io (HList Test.step_list_types))
make_info [] = pure ([], [])
make_info (car :: cdr) = do
cur <- car.geninfo
(rest, restfn) <- make_info cdr
pure $ (cur :: rest, car.work :: restfn)
do_work : HList Test.step_list_types
-> List (HList Test.step_list_types -> io (HList Test.step_list_types))
-> io (HList Test.step_list_types)
do_work infos [] = pure infos
do_work infos (car :: cdr) = do
infos <- car infos
do_work infos cdr
record StepA where
constructor MkStepA
count : Int
record StepB where
constructor MkStepB
name : String
stepA_work : HasIO io => HList Test.step_list_types -> io (HList Test.step_list_types)
stepB_work : HasIO io => HList Test.step_list_types -> io (HList Test.step_list_types)
step_list = [
MkStepData StepA (pure $ MkStepA 0) stepA_work,
MkStepData StepB (pure $ MkStepB "hello") stepB_work
]
stepA_work info = do
let self = info.get StepA
putStrLn "Step A, count \{show self.count}, name \{(info.get StepB).name}"
pure $ info.set StepA $ { count := 42 } self
stepB_work info = do
let self = info.get StepB
putStrLn "Step B, count \{show (info.get StepA).count}, name \{self.name}"
pure info
C++
这是senioria最后使用的技术方案 x (超小声(
C++的类型检查要宽松得多, 同时因为类型系统能力够强, 也有足够的能力去编码这样的类型, 提供比较好的检查方案.
虽然, senioria在一些地方的实现手法似乎免不得有些地狱了 x (超小声(
#include <concepts>
#include <cstdio>
template<class...>
struct HList;
template<class Car, class... Cdr>
HList(Car, HList<Cdr...>) -> HList<Car, Cdr...>;
template<class... Ts>
HList(Ts...) -> HList<Ts...>;
template<class Car, class... Cdr>
struct HList<Car, Cdr...>
{
Car car;
HList<Cdr...> cdr;
HList(Car a, Cdr... d) : car(a), cdr(d...) {}
HList(Car a, HList<Cdr...> d) : car(a), cdr(d) {}
template<class T>
auto &get()
{
if constexpr (std::same_as<T, Car>)
return car;
else
return cdr.template get<T>();
}
template<class T>
auto &get() const
{
if constexpr (std::same_as<T, Car>)
return car;
else
return cdr.template get<T>();
}
auto map(auto fn) { return ::HList { fn(car), cdr.map(fn) }; }
auto foreach (auto fn)
{
fn(car);
cdr.foreach (fn);
}
};
template<class T>
struct HList<T>
{
T car;
HList(T a) : car(a) {}
template<std::same_as<T> S>
auto &get()
{
return car;
}
template<std::same_as<T> S>
auto &get() const
{
return car;
}
auto map(auto fn) { return ::HList { fn(car) }; }
auto foreach (auto fn) { fn(car); }
};
template<>
struct HList<>
{
};
template<class... Ts>
auto work(HList<Ts...> info)
{
auto data = info.map([](auto it) { return it.geninfo(); });
info.foreach ([&](auto it) { it.work(data); });
return data;
}
template<class T, class F>
struct StepInfo
{
T geninfo;
F work;
StepInfo(T g, F w) : geninfo(g), work(w) {}
};
#define ALL_STEPS \
STEP(StepA) \
STEP(StepB)
#define STEP(T) struct T;
ALL_STEPS
#undef STEP
#define STEP(T) , T
template<class A, class... Ts>
using WorkerDataImpl = HList<Ts...>;
using WorkerData = WorkerDataImpl<void ALL_STEPS>;
#undef STEP
struct StepA
{
int count = 0;
static void work(WorkerData &data);
};
struct StepB
{
const char *name = "hello";
static void work(WorkerData &data);
};
void StepA::work(WorkerData &data)
{
printf("at A, count %d, name %s\n", data.get<StepA>().count, data.get<StepB>().name);
data.get<StepA>().count = 42;
}
void StepB::work(WorkerData &data)
{
printf("at B, count %d, name %s\n", data.get<StepA>().count, data.get<StepB>().name);
}
int main()
{
work(HList {
StepInfo { [] { return StepA(); }, StepA::work },
StepInfo { [] { return StepB(); }, StepB::work },
});
}