# Variable number of arguments: generate alignment characters

I'm trying to define a command \letin that should produce something that looks as follows.

Note that it should support a variable number of "let" arguments and a single "in" argument. For example, the following command

\letin{
{x = 1}
{y = 2}
{z = 3}
}{x*y + z}


should expand to

\begin{aligned}[t]
\textbf{let }&x = 1,\\
&y = 2,\\
&z = 3\\
\textbf{in }&x*y + z
\end{aligned}


Based on this answer, I came up with the code below. This seems to work, except for a single alignment character that causes an error. The character is inside the body of an \ifx command, which results in the error Incomplete \ifx; .... Is there a way to solve this?

\usepackage{amsmath}

\newif\ifletinsep       % --> this condition is used to prevent a comma for the last "let" argument
\newcommand*{\letin}[2]{% --> this is the command we want
\begin{aligned}[t]
\textbf{let }
\letinsepfalse
\letinscan#1\relax\\
\textbf{in }&#2
\end{aligned}
}
\newcommand{\letinscan}[1]{% --> this command processes the "let" arguments
\ifx\relax#1\empty
\else
\ifletinsep
, \\
\else
\letinseptrue
\fi
&#1           % ----> when I remove this alignment character, the command executes without error
\expandafter\letinscan
\fi
}


Using your previous display to illustrate the \letin command:

\documentclass{article}
\usepackage{amsmath}

\ExplSyntaxOn

\NewDocumentCommand{\letin}{mm}
{
\safron_letin:nn { #1 } { #2 }
}

\seq_new:N \l__safron_letin_assign_seq

\cs_new_protected:Nn \safron_letin:nn
{
\seq_set_split:Nnn \l__safron_letin_assign_seq { \\ } { #1 }
\openup-\jot
\begin{aligned}[t]
\textbf{let~} & \seq_use:Nn \l__safron_letin_assign_seq { \\ & } \\
\textbf{in~} & #2
\end{aligned}
}

\ExplSyntaxOff

\begin{document}

\begin{align*}
\mathit{True} &\mapsto [\mathit{true}] && \textsc{\small Rule 1} \\
e_1 \mathbin{\mathrm{or}} e_2 &\mapsto
\letin{
e_1 \mapsto [a], \\
e_2 \mapsto [b]
}{[a \lor b]}
&& \textsc{\small Rule 2}
\end{align*}

\end{document}


Here I avoided enlarging \jot, which doesn't seem necessary and I fix another issue I didn't see previously, namely the wrong usage of \operatorname: you want \mathbin{\mathrm{or}} instead.

You can't have “a variable number of arguments” unless you find a way to terminate them. Using a single argument with \\ as a line separator is more intuitive.

Here the \\ is used to separate off the lines, then it's reinserted between the items with also & for the alignment.

• This looks perfect. Thanks again! I will need to do some reading to actually understand though :p Dec 18, 2021 at 15:25
• For everybody else who wants to understand this code, take a look at the reference of expl3: ctan.math.washington.edu/tex-archive/macros/latex/contrib/…. Chapter 19 explains the necessary concepts. Dec 18, 2021 at 15:55

You almost got it right! :-)

But you were "bitten" by two subtleties related to the way in which TeX parses table-cells.

You didn't say whether you can explain how the incomplete ! Incomplete \ifx; all text was ignored after line...-error comes into being, so let's look at your definition of \letinscan:

\newcommand{\letinscan}[1]{% --> this command processes the "let" arguments
\ifx\relax#1\empty
\else
\ifletinsep
, \\
\else
\letinseptrue
\fi
&#1           % ----> when I remove this alignment character, the command executes without error
\expandafter\letinscan
\fi
}


This command is carried out inside an aligned-environment which internally is something like a table where single cells are separated from each other by &.

So there are two problems:

Problem 1:

The content of a table-cell forms a local scope. Thus the assignment \letinseptrue is restricted to the local scope formed by the table-cell. It needs to be preceded with \global.
I suggest preceding the assignment \letinsepfalse within the definition of \letin with \global, too.

Problem 2:

In case #1's first token's meaning equals the current meaning of the control-word-token \relax, the \ifx\relax#1-condition is true, and the \else-branch inclusive the \fi belonging to \ifx is to be skipped. But there is & which is not nested in braces in that branch. Thus at the time of skipping the \ifx's \else-branch TeX "assumes" that the current table-cell ends at that &. This interferes with the \ifx's \else-branch's \fi being somewhere behind that &: As the table-cell ends before encountering the matching \fi, TeX "thinks" that \fi is missing.

You can resolve this by putting & in a set of curly braces that get discarded in case things don't get skipped but get carried out, e.g., as argument of a macro \iden which just spits out its argument.

In the following code four modifications are done to your code so that things work out as expected by you:

\newcommand\iden[1]{#1}  %%%%%%%%% modification 1: added macro \iden
\newif\ifletinsep       % --> this condition is used to prevent a comma for the last "let" argument
\newcommand*{\letin}[2]{% --> this is the command we want
\begin{aligned}[t]%
\textbf{let }%
\global\letinsepfalse %%%%%%%%% modification 2: added \global
\letinscan#1\relax\\%
\textbf{in }&#2
\end{aligned}%
}
\newcommand{\letinscan}[1]{% --> this command processes the "let" arguments
\ifx\relax#1\empty
\else
\ifletinsep
,\\%
\else
\global\letinseptrue  %%%%%%%%% modification 3: added \global
\fi
\iden{&}#1% %%%%%%%%% modification 4: hide "&" in the argument-braces of \iden
\expandafter\letinscan
\fi
}

\documentclass{article}

\usepackage{amsmath, amssymb}

\begin{document}

$\letin{{x = 1}{y = 2}{z = 3}}{x*y + z}$

\noindent\hrule

$\letin{{x = 1}{y = 2}}{x*y}$

\noindent\hrule

$\letin{{x = 1}}{x}$

\noindent\hrule

$\letin{}{\varnothing}$

\end{document}


By now the idea is to iterate until encountering some "sentinel-token" (\relax with your code, \letinstop with my code) denoting the end of the list of arguments.

To be honest, all this \newif\ifletinsep- and \global\letinsepfalse/\global\letinseptrue-assignment-trickery seems cumbersome to me.

Probably expansion-based tail-recursion without any intermediate/temporary assignment for setting an \if..-switch but with another macro-argument, holding emptiness in the first iteration where no separator is to be inserted and holding the ,\\-separator in subsequent iterations, does the trick as well:

\newcommand\letin[2]{%
% #1 - List with a variable number of undelimited "let-arguments".
%      The single "let-arguments" must not contain unbalanced
%      \if../\else/\fi !!!
%      The single "let-arguments" must not have a leading token
%      whose meaning equals the meaning of \letinstop !!!
% #2 - "in-argument"=stuff to append to bold phrase "in ".
\begin{aligned}[t]\textbf{let }%
\letinloop{}#1\letinstop
\\\textbf{in }&#2\end{aligned}%
}%
\newcommand\letinstop{\letinstop}%
\newcommand\secondfirst[2]{#2#1}%
\newcommand\letinloop[2]{%
% #1 - Separator/Tokens to prepend to & and this iteration's
%      "let-argument".
%      In the first iteration this argument is empty.
%      In subsequent iterations this argument holds the tokens: ,\\
% #2 - Either this iteration's "let-argument" or \letinstop-quark.
\ifx\letinstop#2\empty\else\secondfirst{#1&#2\letinloop{,\\}}\fi
}%

\documentclass{article}

\usepackage{amsmath, amssymb}

\begin{document}

$\letin{{x = 1}{y = 2}{z = 3}}{x*y + z}$

\noindent\hrule

$\letin{{x = 1}{y = 2}}{x*y}$

\noindent\hrule

$\letin{{x = 1}}{x}$

\noindent\hrule

$\letin{}{\varnothing}$

\end{document}


Instead of using \ifx for checking if a token with the meaning of the sentinel-token denoting that the end of the list of let-arguments is reached, you can use a mechanism based on delimited arguments for checking the presence of the sentinel-token.
This way—at the cost of defining more macros—you reduce the restrictions from
"the single 'let-arguments' not being allowed to contain unbalanced \if../\else/\fi and the single 'let-arguments' not being allowed to have a leading token whose meaning equals the meaning of \letinstop"
to
"the single 'let-arguments' not being allowed to consist of the single token \letinstop":

As with this approach the presence of the sentinel-token is checked instead of checking meanings via \ifx, you don't need to define the sentinel-token. (Instead of a single sentinel-token with this approach you could as well use a sentinel-phrase consisting of a set of tokens suitable as delimiter of a delimited macro argument.)

\makeatletter
% \CheckWhetherletinstop{<tokens to check>}%
%                       {<Tokens in case <tokens to check> consists
%                          only of the single token \letinstop>}%
%                       {<Tokens in case <tokens to check> does not
%                         consist only of the single token \letinstop>}%
% Test if the argument consists of the single token \letinstop.
% Internally an argument delimited by !\letinstop! is used.
% So first crank out the case of the argument containing ! as
% a case of the argument not being the single token \letinstop.
% Then you are safe that the argument is not something like
%   !\letinstop!whatsoever
% which would also match the delimiter and thus fool the check.
\newcommand\CheckWhetherletinstop[1]{%
% #1 - <tokens to check>.
% \ifcat$\detokenize{<arg to check>}$<true>\else<false>\fi
% checks whether the set of tokens formed by <arg to check>
% is empty. Thus the following checks if #1 contains an
% exclamation-mark which is not nested in curly braces.
\ifcat$\detokenize\expandafter{\gobbletoexclam#1!}$%
\expandafter\@firstoftwo\else\expandafter\@secondoftwo\fi
{\letinstopFork!#1!{\@firstoftwo}!\letinstop!{\@secondoftwo}!!!!}%
{\@secondoftwo}%
}%
\@ifdefinable\gobbletoexclam{\long\def\gobbletoexclam#1!{}}%
\@ifdefinable\letinstopFork{%
\long\def\letinstopFork#1!\letinstop!#2#3!!!!{#2}%
}%
\makeatother

\newcommand\letin[2]{%
% #1 - List with a variable number of undelimited "let-arguments".
%      The single "let-arguments" must not consist of the single
%      token \letinstop !!!
% #2 - "in-argument"=stuff to append to bold phrase "in ".
\begin{aligned}[t]\textbf{let }%
\letinloop{}#1\letinstop
\\\textbf{in }&#2\end{aligned}%
}%
\newcommand\letinloop[2]{%
% #1 - Separator/Tokens to prepend to & and this iteration's
%      "let-argument".
%      In the first iteration this argument is empty.
%      In subsequent iterations this argument holds the tokens: ,\\
% #2 - Either this iteration's "let-argument" or \letinstop-token.
\CheckWhetherletinstop{#2}{}{#1&#2\letinloop{,\\}}%
}%

\documentclass{article}

\usepackage{amsmath, amssymb}

\begin{document}

$\letin{{x = 1}{y = 2}{z = 3}}{x*y + z}$

\noindent\hrule

$\letin{{x = 1}{y = 2}}{x*y}$

\noindent\hrule

$\letin{{x = 1}}{x}$

\noindent\hrule

$\letin{}{\varnothing}$

\end{document}


Remark:

In subsequent coding-examples you find \romannumeral.
This may seem confusing at first glimpse.

In the subsequent coding-examples \romannumeral is not used for obtaining lowercase-roman-representation of whatsoever number but is (ab?)used for triggering a lot of expansion-work and flipping-macro-arguments-around-work so that in case of needing to be in control of expansion you don't need so many/long \expandafter-chains.

\romannumeral usually is used for "gobbling" tokens that form a TeX-⟨number⟩-quantity and in return for them delivering character-tokens which form the representation of the value of that TeX-⟨number⟩-quantity in lowercase roman numerals.
\romannumeral in any case triggers "gobbling" those tokens that form the TeX-⟨number⟩-quantity. But in case that TeX-⟨number⟩-quantity does have a value which is not positive, silently, i.e., without error-message or the like, no tokens at all are delivered in return.
Besides this while searching for tokens belonging to the TeX-⟨number⟩-quantity, expansion of expandable tokens is not suppressed.

Thus you can (ab?)use \romannumeral's searching for tokens belonging to the TeX-⟨number⟩-quantity for having (La)TeX doing a lot of expansion-work and flipping-macro-arguments-around-work as long as it is ensured that in the end a TeX-⟨number⟩-quantity is found whose value is not positive.

In the subsequent coding-examples as "TeX-⟨number⟩-quantity whose value is not positive" the \chardef-token \stopromannumeral/\UD@stopromannumeral is used.

You specified that \letin{{x = 1}{y = 2}{z = 3}}{x*y + z} should expand to

\begin{aligned}[t]
\textbf{let }&x = 1,\\
&y = 2,\\
&z = 3\\
\textbf{in }&x*y + z
\end{aligned}


, and strictly spoken with the above code-examples that set of tokens is not delivered as a whole. Instead, by and by things are delivered and also processed(!) immediately that are further ahead in that set of tokens before delivering and processing the things that are further back in that set of tokens.

If you need the entire set of tokens available as a whole, e.g., for defining a macro from it, or for passing it on as argument of another macro, the approach above can be modified to collect tokens within a macro-argument.

This way the entire set of tokens that forms the result is available as a whole after triggering two expansion-steps on \letin—the first one delivers \letin's toplevel-expansion whose first token is \romannumeral. The second one triggers \romannumeral which in turn triggers subsequent expansion-steps while searching for tokens belonging to the TeX-⟨number⟩-quantity until TeX finds the token \stopromannumeral which denotes a non-positive TeX-⟨number⟩-quantity causing TeX to gobble it and to end the \romannumeral-routine without delivering tokens in return for that ⟨number⟩-quantity-token:

\newcommand\letin[2]{%
% #1 - List with a variable number of undelimited "let-arguments".
%      The single "let-arguments" must not contain unbalanced
%      \if../\else/\fi !!!
%      The single "let-arguments" must not have a leading token
%      whose meaning equals the meaning of \letinstop !!!
% #2 - "in-argument"=stuff to append to bold phrase "in ".
\romannumeral\letinloop{\begin{aligned}[t]\textbf{let }}{}{\\\textbf{in }&#2\end{aligned}}#1\letinstop
}%
\csname @ifdefinable\endcsname\stopromannumeral{\chardef\stopromannumeral=\^^00}%
\newcommand\letinstop{\letinstop}%
\newcommand\fot[2]{#1}%
\newcommand\sot[2]{#2}%
\newcommand\letinloop[4]{%
% #1 - Tokens of result gathered so far.
%      Initially this argument holds the tokens:
%      \begin{aligned}[t]\textbf{let }
% #2 - Separator/Tokens to prepend to & and this iteration's
%      "let-argument".
%      In the first iteration this argument is empty.
%      In subsequent iterations this argument holds the tokens: ,\\
% #3 - Tokens to append to the <tokens of the result gathered so
%      far> when the loop is done; this argument holds:
%      \\\textbf{in }&<in-argument>\end{aligned}
% #4 - Either this iteration's "let-argument" or \letinstop-quark.
\ifx\letinstop#4\empty\expandafter\fot\else\expandafter\sot\fi
{\stopromannumeral#1#3}%
{\letinloop{#1#2&#4}{,\\}{#3}}%
}%

\documentclass{article}

\usepackage{amsmath, amssymb}

\begin{document}

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}{y = 2}{z = 3}}{x*y + z}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}{y = 2}}{x*y}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}}{x}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{}{\varnothing}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\end{document}


If you wish to make sure that things work out when being carried out inside a tabular-environment or inside an alignment or the like, where unbraced & might erroneously be associated with the surrounding table-cell, you can do this by nesting the loop between the curly braces of a macro-argument—\fot (=first of two) in this example:

\newcommand\letin[2]{%
% #1 - List with a variable number of undelimited "let-arguments".
%      The single "let-arguments" must not contain unbalanced
%      \if../\else/\fi !!!
%      The single "let-arguments" must not have a leading token
%      whose meaning equals the meaning of \letinstop !!!
% #2 - "in-argument"=stuff to append to bold phrase "in ".
\romannumeral\expandafter\fot\expandafter{%
\expandafter\stopromannumeral
\romannumeral\letinloop{\begin{aligned}[t]\textbf{let }}{}#1\letinstop
\\\textbf{in }&#2\end{aligned}%
}{}%
}%
\csname @ifdefinable\endcsname\stopromannumeral{\chardef\stopromannumeral=\^^00}%
\newcommand\letinstop{\letinstop}%
\newcommand\fot[2]{#1}%
\newcommand\sot[2]{#2}%
\newcommand\letinloop[3]{%
% #1 - Tokens of result gathered so far.
%      Initially this argument holds the tokens:
%      \begin{aligned}[t]\textbf{let }
% #2 - Separator/Tokens to prepend to & and this iteration's
%      "let-argument".
%      In the first iteration this argument is empty.
%      In subsequent iterations this argument holds the tokens: ,\\
% #3 - Either this iteration's "let-argument" or \letinstop-quark.
\ifx\letinstop#3\empty\expandafter\fot\else\expandafter\sot\fi
{\stopromannumeral#1}{\letinloop{#1#2&#3}{,\\}}%
}%

\documentclass{article}

\usepackage{amsmath, amssymb}

\begin{document}

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}{y = 2}{z = 3}}{x*y + z}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}{y = 2}}{x*y}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}}{x}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{}{\varnothing}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\end{document}


By now the idea was to iterate until encountering some "sentinel-token" (\relax with your code, \letinstop with my code) denoting the end of the list of arguments.

Another approach is keeping the list of arguments as an argument itself and iterating and extracting/removing the first argument "inside" that argument until that argument is blank (=is empty or contains space-tokens only).

• When iterating until reaching a sentinel-token, you may not use the sentinel-token within the list of arguments. When iterating until having "blankness" there is no forbidden token.
• Iterating until having blankness can be implemented expandably without using any \if..\else..\fi-expression at all. Thus there is no danger of user's arguments containing unbalanced \if or \else or \fi fooling the loop. Thus elements of the list of arguments can contain unbalanced \if or \else or \fi. This won't disturb the algorithm. (But if you don't know what you are doing the result may not work out as expected by you.)

• You need a (sub-)routine for checking the "blankness" of a macro argument and you need a (sub-)routine for extracting an undelimited macro-argument's first undelimited macro-argument. Thus there is a lot of code for (sub-)routines. For private purposes I would probably not do this. I would probably only do this for a package where the code must be user-proof/must handle all kinds of weird user-input.
\makeatletter
%%=============================================================================
%% PARAPHERNALIA:
%% \UD@firstoftwo, \UD@secondoftwo, \UD@PassFirstToSecond, \UD@Exchange,
%% \UD@stopromannumeral, \UD@CheckWhetherNull, \UD@CheckWhetherBlank,
%% \UD@ExtractFirstArg
%%=============================================================================
\newcommand\UD@firstoftwo[2]{#1}%
\newcommand\UD@secondoftwo[2]{#2}%
\newcommand\UD@PassFirstToSecond[2]{#2{#1}}%
\newcommand\UD@Exchange[2]{#2#1}%
\@ifdefinable\UD@stopromannumeral{\chardef\UD@stopromannumeral=\^^00}%
%%-----------------------------------------------------------------------------
%% Check whether argument is empty:
%%.............................................................................
%% \UD@CheckWhetherNull{<Argument which is to be checked>}%
%%                     {<Tokens to be delivered in case that argument
%%                       which is to be checked is empty>}%
%%                     {<Tokens to be delivered in case that argument
%%                       which is to be checked is not empty>}%
%%
%% The gist of this macro comes from Robert R. Schneck's \ifempty-macro:
\newcommand\UD@CheckWhetherNull[1]{%
\romannumeral\expandafter\UD@secondoftwo\string{\expandafter
\UD@secondoftwo\expandafter{\expandafter{\string#1}\expandafter
\UD@secondoftwo\string}\expandafter\UD@firstoftwo\expandafter{\expandafter
\UD@secondoftwo\string}\expandafter\UD@stopromannumeral\UD@secondoftwo}{%
\expandafter\UD@stopromannumeral\UD@firstoftwo}%
}%
%%-----------------------------------------------------------------------------
%% Check whether argument is blank (empty or only spaces):
%%-----------------------------------------------------------------------------
%% -- Take advantage of the fact that TeX discards space tokens when
%%    "fetching" _un_delimited arguments: --
%% \UD@CheckWhetherBlank{<Argument which is to be checked>}%
%%                      {<Tokens to be delivered in case that
%%                        argument which is to be checked is blank>}%
%%                      {<Tokens to be delivered in case that argument
%%                        which is to be checked is not blank>}%
\newcommand\UD@CheckWhetherBlank[1]{%
\romannumeral\expandafter\expandafter\expandafter\UD@secondoftwo
\expandafter\UD@CheckWhetherNull\expandafter{\UD@firstoftwo#1{}{}}%
}%
%%-----------------------------------------------------------------------------
%% Extract first inner undelimited argument:
%%.............................................................................
%%   \UD@ExtractFirstArg{ABCDE} yields  A
%%
%%   \UD@ExtractFirstArg{{AB}CDE} yields  AB
%%
%% Due to \romannumeral-expansion the result is delivered after two
%% expansion-steps/after "hitting" \UD@ExtractFirstArg with \expandafter
%% twice.
%%
%% \UD@ExtractFirstArg's argument must not be blank.
%% This case can be cranked out via \UD@CheckWhetherBlank before calling
%% \UD@ExtractFirstArg.
%%
%% Use frozen-\relax as delimiter for speeding things up.
%% Frozen-\relax is chosen because David Carlisle pointed out in
%% <https://tex.stackexchange.com/a/578877>
%% that frozen-\relax cannot be (re)defined in terms of \outer and cannot be
%% affected by \uppercase/\lowercase.
%%
%% \UD@ExtractFirstArg's argument may contain frozen-\relax:
%% The only effect is that internally more iterations are needed for
%% obtaining the result.
%%.............................................................................
\@ifdefinable\UD@RemoveTillFrozenrelax{%
\expandafter\expandafter\expandafter\UD@Exchange
\expandafter\expandafter\expandafter{%
\expandafter\expandafter\ifnum0=0\fi}%
{\long\def\UD@RemoveTillFrozenrelax#1#2}{{#1}}%
}%
\expandafter\UD@PassFirstToSecond\expandafter{%
\romannumeral\expandafter
\UD@PassFirstToSecond\expandafter{\romannumeral
\expandafter\expandafter\expandafter\UD@Exchange
\expandafter\expandafter\expandafter{%
\expandafter\expandafter\ifnum0=0\fi}{\UD@stopromannumeral#1}%
}{%
\UD@stopromannumeral\romannumeral\UD@ExtractFirstArgLoop
}%
}{%
\newcommand\UD@ExtractFirstArg[1]%
}%
\newcommand\UD@ExtractFirstArgLoop[1]{%
\expandafter\UD@CheckWhetherNull\expandafter{\UD@firstoftwo{}#1}%
{\expandafter\UD@stopromannumeral\UD@secondoftwo{}#1}%
{\expandafter\UD@ExtractFirstArgLoop\expandafter{\UD@RemoveTillFrozenrelax#1}}%
}%
%%=============================================================================
%% \letin based on paraphernalia
%%=============================================================================
\newcommand\letin[2]{%
% #1 - List with a variable number of undelimited "let-arguments".
% #2 - "in-argument"=stuff to append to bold phrase "in ".
\romannumeral\letinloop{\begin{aligned}[t]\textbf{let }}{#1}{\\\textbf{in }&#2\end{aligned}}{}%
}%
\newcommand\letinloop[4]{%
% #1 - Tokens of result gathered so far.
%      Initially this argument holds the tokens:
%      \begin{aligned}[t]\textbf{let }
% #2 - List with a variable number of undelimited "let-arguments".
% #3 - Tokens to append to the <tokens of the result gathered so
%      far> when the loop is done; this argument holds:
%      \\\textbf{in }&<in-argument>\end{aligned}
% #4 - Separator/Tokens to prepend to & and this iteration's
%      "let-argument".
%      In the first iteration this argument is empty.
%      In subsequent iterations this argument holds the tokens: ,\\
\UD@CheckWhetherBlank{#2}{\UD@stopromannumeral#1#3}{%
\expandafter\UD@PassFirstToSecond\expandafter{\UD@firstoftwo{}#2}{%
\expandafter\letinloop\expandafter{%
\romannumeral
\expandafter\expandafter\expandafter\UD@Exchange
\expandafter\expandafter\expandafter{%
\UD@ExtractFirstArg{#2}%
}{\UD@stopromannumeral#1#4&}%
}%
}%
{#3}{,\\}%
}%
}%
\makeatother

\documentclass{article}

\usepackage{amsmath, amssymb}

\begin{document}

This is an obscure thing where the fact that when starting a table cell TeX keeps
expanding until a non-expandable token is found is used for removing the first
list-item via \verb|\iffalse|---the items of the argument-list contain unbalanced
\verb|\if..| and \verb|\fi|:

\bigskip

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{ {\iffalse x = 1} {\fi y = 2} {z = 3} }{x*y + z}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}{y = 2}{z = 3}}{x*y + z}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}{y = 2}}{x*y}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{{x = 1}}{x}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\noindent\hrule

\expandafter\expandafter\expandafter\def
\expandafter\expandafter\expandafter\temp
\expandafter\expandafter\expandafter{\letin{}{\varnothing}}

\noindent\texttt{\string\temp:\\\meaning\temp}

$\temp$

\end{document}


• Thank you for the thorough explanation! Now I understand why my own solution didn't work, and I learned a lot from your alternative approach. Dec 19, 2021 at 15:34
• There is one question bothering me though. It seems like my initial approach and your solution take an entirely other route than the solution proposed by @egreg. Should there be reasons to prefer one over the other? Having a background in more classic programming languages, I find the expl3` solution somewhat more intuitive to understand. Dec 21, 2021 at 8:49
• @Safron One reason why the code presented by me shows more classic approaches of TeX-programming rather than showing expl3-based approaches is that egreg, who is much better at programming with expl3 than I am-expl3 is one of the many fields where I couldn't hold a candle to him-, already presented expl3-based methods in his answer. I felt that both "expl3-ways" and "classic ways" of doing things in TeX might be of interest to you. Another reason is your code is written in a more classic way without expl3. Choosing the approach which is most useful to you is up to you. Dec 21, 2021 at 15:06
• @Safron You bring the term "classic" into play. The programming paradigms of TeX as a macro language differ from those of classical object- and function- and procedure-based programming languages like PASCAL/C++/Java/etc. For example, classical programs (except for things like compiler-directives) are executed after compilation. TeX programs are executed during compilation. For me it's the other way around than for you: When I started with expl3, I was familiar with some "classic ways" of programming in (La)TeX and I had great difficulty in understanding expl3 code. :-) Dec 21, 2021 at 15:11
• @Safron Many people hope from expl3 that they don't have to get used to TeX's being different from "classical programming languages". But this hope is based on a misunderstanding: expl3 is intended to relieve TeX programmers under the hood of having to write long code-sequences in the classical ways of TeX programming and hereby writing a lot of things just to cope with whatsoever subtle aspects of the way in which TeX works and might "bite" you if you are not aware of subtleties. But expl3 doesn't manage relieving you to one hundred percent from knowing about how TeX works and probably ... Dec 21, 2021 at 15:16