大数跨境
0
0

DAX 中的用户定义函数介绍

DAX 中的用户定义函数介绍 PowerPivot工坊
2025-09-28
0

图片


本文翻译自Marco Russo & Alberto Ferrari的文章—《IIntroducing user-defined functions in DAX 来源:SQLBI   用户定义函数是 DAX 中一项令人振奋的新功能。本文我们将概述在 Power BI 项目中使用该功能前需理解的关键概念。


尽管 DAX 是一种函数式语言,但其此前并未提供用户自定义函数的选项。从 2025 年 9 月版本开始,用户可以定义函数——这些可参数化的表达式可在整个语义模型中重复使用。本文将阐述函数的工作原理,您可通过观看相关视频了解函数定义的用户界面操作。

用户定义函数既可在单个语义模型内共享通用业务逻辑,也可在不同模型间实现逻辑复用。您可从 https://daxlib.org/ 获取 DAX 用户定义函数库,该免费开源存储库提供与模型无关的 DAX 函数,可轻松导入并应用于您的模型中。

掌握函数最便捷的方式是在查询中直接定义并即时验证结果。以下为首个示例:

DEFINE
FUNCTIONSumTwoNumbers =(A,)=> A +B
EVALUATE
    {SumTwoNumbers (10,20)}


函数定义包含签名部分(名称及参数:A、B)与函数体部分(A + B),两者通过 => 符号分隔。调用函数时,需像调用原生 DAX 函数一样使用函数名后接参数的形式。

为避免混淆,我们始终对用户定义函数采用帕斯卡命名法(Pascal Case)。这样既能与始终全大写格式的预定义 DAX 函数形成区分,又能通过帕斯卡命名清晰标识用户定义函数。

在定义参数时,可选择参数类型、子类型及参数传递模式。其中最关键的是参数传递模式,本章后续将专设章节详述。参数传递模式共有两种,其选择将显著影响函数行为;相比之下,参数类型和子类型的影响较小。

参数传递模式说明:

  • VAL(值模式):参数在函数调用前,于调用方的评估上下文中完成求值。VAL 参数在函数体执行期间具有唯一确定值,对同一参数的多次求值结果始终一致。

  • EXPR(表达式模式):参数作为表达式,将在函数体中使用该参数的评估上下文中进行求值。对 EXPR 参数的多次求值可能(且往往)会产生不同结果。

参数传递模式可通过冒号(:)与参数关联定义,如下例所示:

FUNCTIONSumTwoNumbers =(A : Val,B : Expr )=> A +B
开发人员还可为参数指定类型,可从下表中列出的多种选项中进行选择。

参数类型可与参数传递模式一同指定,如下例所示:
FUNCTIONSumTwoNumbers =(
    a : SCALARVAL,
    b : SCALAREXPR
)=> a +b

函数会对参数进行自动类型转换,这意味着用于初始化参数的表达式会被强制转换(即类型转换)为所需的数据类型。自动类型转换可能会引发一些困惑,因此有必要进一步解释。

我们来定义一个参数数据类型为整数的函数。当该函数接收整数类型的参数时,一切都会按预期运行。例如,传入数字 3 和 2,会得到预期结果 5:

DEFINE
FUNCTIONSumTwoNumbers =(
    a : INT64VAL,
    b : INT64VAL
)=> a +b

EVALUATE
{
    SumTwoNumbers(3,2)
}
然而,如果调用该函数时传入两个小数,也不会产生错误,这正是因为自动类型转换机制的存在。实际上,在函数执行计算之前,每个实参都会被自动转换为所需的数据类型:
DEFINE
FUNCTIONSumTwoNumbers =(
    a : INT64VAL,
    b : INT64VAL
)=> a +b

EVALUATE
{
    SumTwoNumbers(3.4,2.4),       -- evaluates INT ( 3.4 ) + INT ( 2.4 )
    SumTwoNumbers("3.4","2.4")    -- evaluates INT ( "3.4" ) + INT ( "2.4" )
}
尽管这两个小数相加的结果本应是 5.8(若四舍五入为整数则是 6),但由于每个参数都被分别强制转换为整数,最终得到的结果是 3+2=5。
理解参数传递模式
可以使用冒号(:)为参数添加参数传递模式,如下定义所示:
DEFINE
FUNCTIONSumTwoNumbers =(a : VAL,b : VAL)=> a +b

EVALUATE
    {SumTwoNumbers(10,20)}

在上述定义的函数中,参数 a 和 b 均为值参数。默认情况下,函数的实参都属于值参数。不过,也可以通过使用 EXPR 强制将其视为表达式参数。理解二者的区别至关重要,因为若使用了错误的参数传递模式,很可能会导致代码中出现难以调试的故障。

调用者会在函数执行前对值参数进行计算。函数接收参数的值作为实参,并将其用于自身的运算过程。值参数的特性类似于变量定义:调用者使用的表达式会生成一个值,该值被赋值给一个变量后,函数才开始执行。请看以下函数示例:

FUNCTIONSumTwoNumbers =(a : VAL,b : VAL)=> a +b
由于这两个参数均为 VAL 参数,代码会按如下方式转换:
--
-- When the function is invoked with two arguments like this:
--
    SumTwoNumbers(
        SUM(Sales[Net Price]),
        SUM(Sales[Quantity])
    )
--
-- The code being executed is equivalent to this:
--
    VAR=SUM(Sales[Net Price])
    VAR=SUM(Sales[Quantity])
    RETURN
        +b
另一方面,表达式参数不会由调用者进行计算。表达式参数会以表达式(公式)的形式传递,并且每当函数使用它们时,才会对其进行计算。根据参数被计算时所处的有效计算上下文,表达式参数在函数体的不同部分可能会有不同的值。以下是我们之前使用过的同一个 SumTwoNumbers 函数,此次使用的是表达式参数(EXPR):

FUNCTIONSumTwoNumbers =(a : EXPR,b : EXPR)=> a +b

由于这两个参数均为 EXPR 参数,因此该代码等效于以下形式:
--
-- When the function is invoked with two arguments like this:
--
    SumTwoNumbers(
        SUM(Sales[Net Price]),
        SUM(Sales[Quantity])
    )
--
-- The code being executed is equivalent to this:
--
    SUM(Sales[Net Price])+SUM(Sales[Quantity])

在我们目前展示的示例中,无论使用 VAL 参数还是 EXPR 参数,函数的结果都没有差异。然而在大多数情况下,确定正确的传递模式需依赖函数的语义 —— 这一点对函数的行为具有深远影响。

使用 VAL 参数是默认行为,当函数需要基于某个特定值来执行运算时,都应使用 VAL 参数(可隐式或显式声明,但建议始终显式声明)。当函数需要在潜在的不同上下文中对表达式进行计算,且需根据自身逻辑产生不同结果时,则必须使用 EXPR 参数。我们通过一个示例来理解这一点。

以下函数旨在计算一个表达式的值,且计算范围仅限于红色产品。该函数无法按预期工作,因为参数传递模式默认是 VAL,但此函数需要使用 EXPR 参数才能正确运行:

FUNCTIONComputeForRed =(amount )=>
    CALCULATE(
        amount,
        'Product'[Color]="Red"
    )
假设我们调用该函数时,将 “销售额” 度量值(Sales Amount measure)作为 “金额” 参数(amount argument)传入。在这种情况下,代码会通过一个变量进行转换,这会使得 CALCULATE 函数所执行的筛选上下文变更效果失效:
--
-- When the function is invoked with an argument like this:
--
    ComputeForRed([Sales Amount])
--
-- The code being executed is equivalent to this:
--
    VARamount =[Sales Amount]
    RETURN
        CALCULATE(
            amount,
            'Product'[Color]="Red"
        )
ComputeForRed 的函数体对 CALCULATE 内部的参数进行计算,在此过程中,它会修改筛选上下文,强制将产品颜色设为 “红色”。然而,对于 VAL(值传递)参数,实参的计算会在函数执行之前完成。在之前的代码片段中,我们使用 amount 变量来模拟这种行为。由于该参数是常量,无论筛选上下文如何变化,它都保持不变。尽管函数体是在仅筛选红色产品的筛选上下文中对该参数进行计算,但最终结果仍是所有可见产品颜色的销售额(Sales Amount)。
将参数传递模式改为 EXPR(表达式传递)后,该函数便能正常工作:
FUNCTIONComputeForRed =(amountExpr : EXPR)=>
    CALCULATE(
        amountExpr ,
        'Product'[Color]="Red"
    )
让我们通过一个更贴近实际的示例来进一步阐述这个主题。我们创建一个表函数,用于根据某个指标返回最佳客户。如果某个客户的该指标值大于所有客户的该指标平均值,则该客户被视为最佳客户。我们希望将这个指标作为函数的参数之一。以下是定义并执行 BestCustomers 函数的查询:
DEFINE
FUNCTIONBestCustomers =(metricExpr : EXPR)=>
    VARAverageMetric =AVERAGEX(Customer,metricExpr )
    VARBestCustomersResult =
        FILTER(
            Customer,
            metricExpr > AverageMetric
        )
    RETURN
        BestCustomersResult
EVALUATE
    BestCustomers([Sales Amount])

这段代码中有几个细节值得一提:

metricExpr 是一个 EXPR 参数,每次调用时都会进行计算。在函数体中,两个不同的迭代器引用了 metricExpr,因此,metricExpr 会在每个迭代器的每一行中都进行计算。

在 AverageMetric 的定义中,metricExpr 是在 AVERAGEX 内部进行计算的,每个客户计算一次。请注意,计算是在对客户的迭代过程中进行的,AVERAGEX 会在此过程中创建一个行上下文。因此,metricExpr 是在行上下文中进行计算的。

在计算 BestCustomers 时,metricExpr 会在 FILTER 迭代内部再次进行计算。在这种情况下,存在一个不同的行上下文,此次是由 FILTER 创建的。因此,metricExpr 会在 FILTER 创建的行上下文中,针对每个客户计算一次。

metricExpr 参数会在不同的计算上下文中进行多次计算,因此,它每次计算都会产生不同的结果。这并非偶然。为了让函数正常工作,我们确实需要 metricExpr 产生不同的结果。实际上,metricExpr 会在两次不同的迭代中,处于两个不同的行上下文被调用,且每次都需要通过上下文转换来计算当前正在迭代的客户的销售额。

该函数会产生正确的结果;执行后,结果包含 1807 个客户,其中一些客户在下面的截图中展示。

将参数类型改为 VAL(值传递)会产生空结果。以下查询返回的是一个空表,而此版本与上一版本的唯一区别就在于参数传递模式。
DEFINE
FUNCTIONBestCustomers =(metricVal : VAL)=>
    VARAverageMetric =AVERAGEX(Customer,metricVal )
    VARBestCustomersResult =
        FILTER(
            Customer,
            metricVal > AverageMetric
        )
    RETURN
        BestCustomersResult
EVALUATE
    BestCustomers([Sales Amount])

空结果的产生原因在于:值传递(VAL)参数仅在函数调用时计算一次,之后不再重新计算。无论在何种计算上下文中使用该参数,函数每次访问它时,得到的结果都是相同的。

在 VAL 参数的示例中,“销售额”(Sales Amount)度量值在 BestCustomers 函数调用前就已完成计算。因此,metricVal 参数中存储的是所有客户的销售额总和。随后函数运行时,AverageMetric(平均指标)本质上是对一系列完全相同的值求平均(因为此时 metricVal 不再重新计算,仅被读取)。最终,FILTER(筛选)函数不会返回任何结果 —— 因为筛选条件要判断一个值是否严格大于其自身,这一判断对所有行而言结果均为 “假”(FALSE)。而在之前 EXPR(表达式传递)参数的示例中,每次引用 metricExpr 时,“销售额” 度量值都会重新计算,就如同 metricExpr 被直接替换成了 “销售额” 度量值的引用一样。这样的机制最终能产生符合预期的结果。

值传递(VAL)是参数传递的默认方式,因为这种使用参数的方式最符合直觉。但正如最后两个示例所展示的,在许多函数中,通过 EXPR 参数注入部分代码(即传递表达式而非固定值)会更有帮助。

使用表达式参数(EXPR)时,必须密切关注函数对该参数的使用方式。例如,我们可以修改查询代码:此次我们仍使用表达式参数,但在调用函数时,会用 SUMX 函数替代 “销售额” 度量值。

DEFINE
FUNCTIONBestCustomers =(metricExpr : EXPR)=>
    VARAverageMetric =AVERAGEX(Customer,metricExpr )
    VARBestCustomersResult =
        FILTER(
            Customer,
            metricExpr > AverageMetric
        )
    RETURN
        BestCustomersResult
EVALUATE
    BestCustomers(SUMX(Sales,Sales[Quantity]*Sales[Net Price]))

相当令人意外的是,该查询返回的是一个空表。此次定位问题需要多花些功夫,因为参数已设置为正确的类型(EXPR),因此问题出在其他地方。实际上,这次的问题在于迭代过程中缺少上下文转换

在之前的 EXPR 参数示例中,BestCustomers 函数的实参是一个度量值引用,即 “销售额”(Sales Amount)。而在最后这个 EXPR 参数示例中,我们使用了一个包含 SUMX 的简单公式 —— 这个公式本就对应 “销售额” 度量值的定义。当我们将度量值引用作为 metricExpr 参数的实参时,每当在 BestCustomers 函数中引用 metricExpr 参数,都会发生隐式上下文转换,代码也因此能按预期运行。但使用 SUMX 表达式时,上下文转换不会发生,因为缺少了围绕度量值引用的隐式 CALCULATE 函数(该函数是触发上下文转换的关键)。

参数与度量值不同:参数不会自动发生上下文转换。正因为没有上下文转换,表达式的计算不会受到 FILTER 和 AVERAGEX 创建的行上下文的影响。EXPR 参数的实参中,“度量值引用” 与 “普通表达式” 的区别至关重要 —— 这种区别让函数拥有更强的控制能力和灵活性,若使用得当,还可能提升性能;但同时也必须谨慎处理,以避免出现意外结果。

要恢复代码的正确行为,需要显式使用 CALCULATE 函数:

DEFINE
FUNCTIONBestCustomers =(metricExpr : EXPR)=>
    VARAverageMetric =AVERAGEX(Customer,metricExpr )
    VARBestCustomersResult =
        FILTER(
            Customer,
            metricExpr > AverageMetric
        )
    RETURN
        BestCustomersResult
EVALUATE
    BestCustomers(CALCULATE(SUMX(Sales,Sales[Quantity]*Sales[Net Price])))
添加外部的 CALCULATE 函数会强制触发一次上下文转换,从而使查询返回 1807 个客户。但在编写函数时,我们必须特别留意这些细节。与其修改函数的调用方式,不如调整函数代码,在需要时主动激活上下文转换。在我们的示例中,只需在每次在行上下文中计算 metricExpr 时添加 CALCULATE 函数即可 —— 这样做能清晰地表明我们需要进行上下文转换,具体代码如下:
DEFINE
FUNCTIONBestCustomers =(metricExpr : EXPR)=>
    VARAverageMetric =AVERAGEX(Customer,CALCULATE(metricExpr ))
    VARBestCustomersResult =
        FILTER(
            Customer,
            CALCULATE(metricExpr )> AverageMetric
        )
    RETURN
        BestCustomersResult
EVALUATE
    BestCustomers(SUMX(Sales,Sales[Quantity]*Sales[Net Price]))

此版本的函数无论接收何种参数都能正常工作:无论是传入公式还是度量值,效果都一样好。当需要上下文转换时,函数代码会通过显式的 CALCULATE 函数强制触发转换。这看似只是一个小细节,但正是它区分了新手编写的函数和 DAX 专业人员编写的函数。专业人员编写的函数,即便在非预期的使用场景下,也能确保正常运行。

最后,在编写函数时,始终建议以最高效的方式优化代码。原因在于,函数通常会被多个度量值和其他函数调用;因此,编写高效的代码可能会对语义模型的性能产生深远影响。此函数的最新版本通过将每个客户的 metricExpr 计算结果整合到一个变量中,减少了该指标(metric)的执行次数,具体代码如下:

DEFINE
FUNCTIONBestCustomers =(metricExpr : EXPR)=>
    VARCustomersAndMetric =
        ADDCOLUMNS(
            Customer,
            "@Metric",CALCULATE(metricExpr )
        )
    VARAverageMetric =AVERAGEX(CustomersAndMetric,[@Metric])
    VARBestCustomersResult =
        FILTER(
            CustomersAndMetric,
            [@Metric]> AverageMetric
        )
    RETURN
        BestCustomersResult
EVALUATE
    BestCustomers(SUMX(Sales,Sales[Quantity]*Sales[Net Price]))
结论

用户定义函数(User-defined functions)是 DAX 开发者的重要工具。通过创建函数,开发者可以将模型代码拆分为更小、更易于管理的模块,这些模块便于独立测试和调试。经过全面验证与优化后,每个函数都会成为一个 “构建块”,为整个项目的稳健性奠定基础。

在开发函数时,有两个关键点必须考虑:一是参数传递模式(parameter-passing modes);二是对于表达式参数(expression parameters),需明确评估是否需要用 CALCULATE 函数封装参数,以支持上下文转换(context transitions)。

我们很难想象,一个复杂的语义模型(sophisticated semantic model)会包含大量度量值(measures),却没有用户定义函数。传统上,度量值和计算组(calculation groups)承担着处理计算复杂性的任务,但这往往会导致性能下降和代码可读性降低。而用户定义函数提供了一种高效的解决方案:既能对复杂计算逻辑进行抽象封装,又不会牺牲性能。


END

图片

【声明】内容源于网络
0
0
PowerPivot工坊
提供Power Pivot,Power Query等Power BI技术相关文章,培训咨询等服务。
内容 648
粉丝 0
PowerPivot工坊 提供Power Pivot,Power Query等Power BI技术相关文章,培训咨询等服务。
总阅读198
粉丝0
内容648