利用异构列表构建可拓展的执行流在不同语言里的简单实践

这是 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 },
    });
}

果然类型系统越简单越好配平 ;;><;; (超小声(缩成球(

js随手搞搞就完了, 配c++花的时间以分钟计, 但配平idris2和agda的时间加起来要按小时算 ;;><;; (超小声(缩成球(

至于为什么要这么做… 越强的类型系统描述和约束的能力就越强… x x (超小声(
(虽然即使是idris2, 在这个例子里恐怕也只是和c++一个级别… x x (超小声(