4、WDL数据类型与表达式:基本数据类型、数组、映射、可选类型、条件表达式

WDL 的类型系统,说白了就是给 Cromwell 看的“数据说明书”。

你给 Cromwell 传了个文件,它得知道那是普通文本还是数组?是必填还是可选?如果你连数据类型都搞混了,任务跑起来十有八九会翻车。

我个人习惯是在写 WDL 前,先在心里把数据流画一遍。下面这张图,就是我常用的思考结构——从输入到输出,类型是怎么一层层套上去的。

WDL 类型系统与表达式结构图 输入数据 基本类型 Int / Float / String Boolean / File 复合类型 Array[T] Map[K,V] Pair[L,R] 可选类型 T? select_first / select_all 条件表达式 & 函数调用 输出

4.1 基本数据类型

WDL 的基本类型不多,就五个:Int、Float、String、Boolean、File。

跟别的语言不一样的是:WDL 里 Int 是 64 位整数,Float 是双精度浮点数。我刚开始用的时候,Float 传进去想把 Int 接住,结果是 Cromwell 直接报类型错误——嗯,这里要注意,类型必须严格匹配。

📌 个人建议: 命令行传参时尽量用 String 代替 File 做中间传递,可以避免很多路径解析的坑。

每个文件路径都会被 Cromwell 解析为绝对路径,这是跟其他语言最大的区别。来看个简单例子:

workflow example {
  File ref_genome
  Int threads = 4
  String sample_name
  
  call align {
    input:
      ref = ref_genome,
      cpu = threads,
      name = sample_name
  }
}

如果你在 Int 后边不写默认值,它就成了必填项。这点跟编程语言不太一样,我习惯把所有参数都显式标注默认值,避免后面 call workflow 时漏掉。

4.2 数组类型

数组在 WDL 里用 Array[T] 表示,T 可以是任意类型。

我在做批量数据的重分析时,经常遇到几千个样本。这时候用 Array[File] 把输入文件一次性传进来,比逐个传参方便太多了。

数组的几种写法:

# 显式声明
Array[String] names = ["sample1", "sample2", "sample3"]

# 混合类型也可以(不推荐)
Array[String] mixed = ["text", "123"]  

# 空数组声明
Array[File] empty_files = []

# 索引访问(从0开始)
String first = names[0]
⚠️ 避坑指南: 我第一次用数组时,把 Array[String] 和 Array[File] 混在一起传给了同一个 task。Cromwell 不会自动做类型转换,文件路径会变成字符串字面量。所以,File 类型和 String 类型绝对不能互换。

数组有两个常用的内置方法:

方法说明返回值类型
length(names)取数组长度Int
flatten(nested)展平嵌套数组Array[T]
select_first(arr)取第一个非空元素T

举个例子,我们经常需要从多份比对结果中选一份最佳的:

Array[File] bams = ["sample1.bam", "sample2.bam"]
String cmd = "samtools merge merged.bam ~{bams}"
# 这样写会被 WDL 展开为:
# samtools merge merged.bam sample1.bam sample2.bam

4.3 映射类型

Map[K, V] 在 WDL 里用途很广。关键点在于:键必须是基本类型(Int 或 String),值可以是任意类型,包括数组。

我曾经接手一个项目,里边的样本信息存在 CSV 里。用 Map[String, String] 读取后,直接通过 key 取值,代码一下子就清爽了。

Map 的典型用法:

Map[String, String] sample_meta = {
  "NA12878": "WGS",
  "NA12877": "WES"
}

String type = sample_meta["NA12878"]  # 返回 "WGS"

# 取值时注意:如果 key 不存在,Cromwell 会报错
String result = sample_meta["NA00000"]  # 运行时错误

还有一个常见问题——Map 遍历。WDL 标准语法不支持直接遍历 Map 的键值对,需要先拿到 keys 数组:

Array[String] sample_ids = keys(sample_meta)

# 在 scatter 里遍历
scatter (id in sample_ids) {
  call process {
    input:
      sample_id = id,
      type = sample_meta[id]
  }
}

4.4 可选类型

可选类型是 WDL 里最有特色的东西。用 T? 表示——说白了就是“可能有值,可能没有”。

为什么需要它?因为 WDL 里你不能依赖“运行时判断”,所有类型必须在编译期就确定。可选类型就是为了处理“缺失值”而生的。

来看个例子:

# 这个参数可以不传
String? description

# 在 task 里使用时必须解包
task check {
  String? desc
  command {
    echo "~{if defined(desc) then desc else 'no description'}"
  }
}
📌 实践技巧: 当你用 select_first 时,如果数组里全是 None,运行时会直接报错。我建议先用 defined() 判断,再做操作。

可选类型的操作我有三招:

  • select_first(Array[T?]):从列表里取第一个非空值。
  • select_all(Array[T?]):过滤掉所有空值,返回非空数组。
  • defined(variable):判断变量是否有值,返回 Boolean。

我曾经在写批量测序数据处理时,某个样本的质检报告是可选的。用 File? 声明后,在 task 里用 defined() 决定是否执行质检步骤——这正是可选类型的妙用:你不必为“没有数据”而去写一堆空文件。

4.5 条件表达式

WDL 里没有 if-else 语句,只有 if-then-else 表达式。语法是:

String result = if (condition) then "yes" else "no"

这个表达式会返回一个值,所以你可以直接赋值给变量。举个例子,我曾经在临床分析中,根据样本类型决定使用哪个参考基因组:

String ref = if (sample_type == "tumor") 
  then "/refs/hg38_tumor.fa" 
  else "/refs/hg38_normal.fa"

⚠️ 注意: 条件表达式返回的必须是相同类型。如果你写:

# 错误写法
Int a = if (true) then 1 else "text"  # 类型不匹配

这样会在 Cromwell 编译时就报错。

条件式还可以嵌套:

Int quality = if (depth >= 30) 
  then if (mapping_rate > 0.95) 
    then 3 
    else 2 
  else 1

是不是看着有点啰嗦?

我刚开始也觉得,为什么 WDL 不直接用 if-else 语句?后来理解了——WDL 不关心“执行流程”,它只关心“数据怎么变换”。所有的条件都应该是表达式,而不是控制流语句。这也是 Cromwell 能并行优化执行的核心原因之一。

⛔ 我曾经踩过的坑: 在 scatter 块内部用条件表达式来控制 task 是否执行。这是行不通的。条件的值可以改变,但 scatter 里的 task 调用必须在编译期确定。正确的做法是:用一个 select_first 或者 if ... then 来过滤掉不需要的数据,而不是在 scatter 里加条件。

最后回到开头那张图:基本类型是最底层的数据单元,数组和映射把它们串起来,可选类型处理缺失情况,条件表达式则负责根据数据内容动态选择路径。把这四样东西玩明白了,绝大部分 WDL 需求都能表达得干干净净。