替换字符串中的变量

20

我正在使用PHP开发一个多语言的网站,在我的语言文件中经常有包含多个变量的字符串,这些变量稍后将被填入以完成句子。

目前,我在字符串中放置{VAR_NAME}并在使用时手动替换每个出现的变量名,用相应的值进行匹配。

所以基本上:

{X}在{Y}上创建了一个主题

会变成:

Dany在 Stack Overflow 上创建了一个主题

我已经考虑过sprintf,但我认为它不方便,因为它取决于变量的顺序,而这种顺序可能因语言而异。

我已经查看了How replace variable in string with value in php?,目前我基本上使用这种方法。

但是,我想知道是否有一种内置的(或者说非内置的)方便的PHP方法来处理这个问题,因为在前面的示例中,我已经有了名为X和Y的变量,更像是变量变量 $$。

因此,我可能不会在字符串上执行str_replace,而是会调用类似如下的函数:

$X = 'Dany';
$Y = 'Stack Overflow';
$lang['example'] = '{X} created a thread on {Y}';

echo parse($lang['example']);

还会打印出:

Dany在Stack Overflow上创建了一个线程

谢谢!

编辑

这些字符串作为模板使用,可以使用不同的输入多次使用。

所以基本上做 "{$X} ... {$Y}" 不会奏效,因为我会失去模板,并且该字符串将使用尚未确定的$X$Y的起始值进行初始化。


还可以查看此答案中的第二个选项,它使用strtr来避免需要循环。[这是在稍后将其添加到本问题提及的链接的已接受答案中的。] - ToolmakerSteve
12个回答

45

我会在这里添加一个答案,因为我认为当前的答案都不太合适。我将直接展示我用来实现此操作的代码:

function parse(
    /* string */ $subject,
    array        $variables,
    /* string */ $escapeChar = '@',
    /* string */ $errPlaceholder = null
) {
    $esc = preg_quote($escapeChar);
    $expr = "/
        $esc$esc(?=$esc*+{)
      | $esc{
      | {(\w+)}
    /x";

    $callback = function($match) use($variables, $escapeChar, $errPlaceholder) {
        switch ($match[0]) {
            case $escapeChar . $escapeChar:
                return $escapeChar;

            case $escapeChar . '{':
                return '{';

            default:
                if (isset($variables[$match[1]])) {
                    return $variables[$match[1]];
                }

                return isset($errPlaceholder) ? $errPlaceholder : $match[0];
        }
    };

    return preg_replace_callback($expr, $callback, $subject);
}

那个函数是干什么用的?

简而言之:

  • 使用指定的转义字符创建一个正则表达式,该正则表达式将匹配三个序列中的一个(稍后会详细说明)
  • 将其提供给preg_replace_callback(),其中回调精确处理其中两个序列,并将其他所有内容视为替换操作。
  • 返回结果字符串

正则表达式

正则表达式将匹配以下三个序列中的任何一个:

  • 两个转义字符的出现,后跟零个或多个转义字符的出现,后跟一个左花括号。只有前两个转义字符的出现被消耗。这将被替换为一个单独的转义字符。
  • 一个转义字符的出现,后跟一个左花括号。这将被替换为一个字面上的左花括号。
  • 一个左花括号,后跟一个或多个Perl单词字符(字母数字和下划线字符),后跟一个右花括号。这被视为一个占位符,并在$variables数组中查找花括号之间的名称,如果找到,则返回替换值,否则返回$errPlaceholder的值。默认情况下,这是null,它被视为特殊情况,将返回原始占位符(即不修改字符串)。

为什么它更好?

要了解为什么它更好,请看其他答案采用的替换方法。除了一个例外(其失败只是与PHP<5.4兼容性和稍微不太明显的行为),它们分为两类:

  • strtr() - 它不提供处理转义字符的机制。 如果您的输入字符串需要字面上的{X}怎么办? strtr()没有考虑到这一点,它会被替换为值$X
  • str_replace() - 它存在与strtr()相同的问题,以及另一个问题。当您使用搜索/替换参数的数组参数调用str_replace()时,它的行为就好像多次调用它一样-每个替换对数组都调用一次。这意味着如果您的替换字符串之一包含在搜索数组中稍后出现的值,则最终将替换该字符串。

为了演示str_replace()的此问题,请考虑以下代码:

$pairs = array('A' => 'B', 'B' => 'C');
echo str_replace(array_keys($pairs), array_values($pairs), 'AB');

现在你可能期望这里的输出是BC,但实际上它将会是CC (demo) - 这是因为第一个迭代用B替换了A,而在第二个迭代中主题字符串是BB - 所以这两个B的出现都被替换为C

这个问题还揭示了一个性能考虑,这可能不是显而易见的 - 因为每对替换都是单独处理的,所以操作是O(n),对于每个替换对,整个字符串都会被搜索并处理单个替换操作。如果你有一个非常大的主题字符串和很多替换对,那么就会在引擎盖下进行一个相当大的操作。

可以说,这种性能考虑是无关紧要的 - 你需要一个非常大的字符串和很多的替换对才能得到有意义的减速,但仍然值得记住。同时也值得记住,正则表达式本身也有性能惩罚,因此通常不应该将此考虑因素纳入决策过程中。

相反,我们使用preg_replace_callback()。它可以访问任何给定的字符串部分一次,寻找与提供的正则表达式匹配的内容。我添加了这个限定符,因为如果你写一个导致灾难性回溯的表达式,那么它将会超过一次,但在这种情况下,这不应该是一个问题(为了避免这种情况,我在表达式中仅有的重复使用了占有量词)。

我们使用preg_replace_callback()而不是preg_replace(),以便我们在查找替换字符串时应用自定义逻辑。

这使你能够做到什么

来自问题的原始示例

$X = 'Dany';
$Y = 'Stack Overflow';
$lang['example'] = '{X} created a thread on {Y}';

echo parse($lang['example']);

这将变成:

$pairs = array(
    'X' = 'Dany',
    'Y' = 'Stack Overflow',
);

$lang['example'] = '{X} created a thread on {Y}';

echo parse($lang['example'], $pairs);
// Dany created a thread on Stack Overflow

更高级的某些内容

现在假设我们有:

$lang['example'] = '{X} created a thread on {Y} and it contained {X}';
// Dany created a thread on Stack Overflow and it contained Dany

...而我们希望第二个 {X} 字符串中也能被字面地显示出来。使用默认的转义字符 @,我们需要改为:

$lang['example'] = '{X} created a thread on {Y} and it contained @{X}';
// Dany created a thread on Stack Overflow and it contained {X}

好的,到目前为止看起来不错。但是如果那个@符号本应该是一个字面量呢?

$lang['example'] = '{X} created a thread on {Y} and it contained @@{X}';
// Dany created a thread on Stack Overflow and it contained @Dany

请注意,正则表达式被设计成只关注紧接着左花括号的转义序列。这意味着你不需要转义转义字符,除非它出现在占位符的前面。

关于使用数组作为参数的说明

原始代码示例使用了与字符串中占位符同名的变量。我的代码使用了具有命名键的数组,这样做有两个非常好的理由:

  1. 清晰性和安全性——这样能更容易地看到最终将被替换的内容,而且你不会冒意外替换你不想暴露的变量的风险。如果有人可以简单地输入 {dbPass} 并查看你的数据库密码,那就没什么用了,对吧?
  2. 范围——除非调用者是全局范围,否则无法从调用范围导入变量。如果从另一个函数调用该函数,则该函数无用,并且从另一个范围导入数据是非常糟糕的实践。

如果你真的想使用当前范围内的命名变量(由于上述安全问题,我不建议这样做),你可以将 get_defined_vars() 的调用结果传递给第二个参数。

选择转义字符的说明

你会注意到我选择了 @ 作为默认的转义字符。你可以使用任何字符(或字符序列,可以是多个),只需将其传递给第三个参数——你可能会想使用 \ 因为许多语言都使用它,但在这样做之前先等一下

不要使用 \ 的原因是因为许多语言把它作为自己的转义字符,这意味着当你想在 PHP 字符串字面值中指定你的转义字符时,你会遇到这个问题:

$lang['example'] = '\\{X}';   // results in {X}
$lang['example'] = '\\\{X}';  // results in \Dany
$lang['example'] = '\\\\{X}'; // results in \Dany

这可能会导致阅读上的噩梦,以及在处理复杂模式时出现一些不明显的行为。选择一个未被任何其他相关语言使用的转义字符(例如,如果你正在使用这种技术来生成HTML片段,则不要使用&作为转义字符)。

总之

你所做的事情有边缘情况。要正确解决问题,您需要使用能够处理这些边缘情况的工具——而在字符串操作方面,处理该任务的工具通常是正则表达式。


5
非常好的回答,包含了我所寻找的所有详细解释,感谢你分享你的经验,尤其是关于它为什么更好的部分,我非常感谢你花时间写这篇文章 :) - Dany Khalife
这似乎太多了,仅仅替换字符串... 在我看来。 - t1gor
1
如果你只需要字符串替换,那么使用 str_replace() 即可。但是,如果你需要一个合适的模板系统,那么 str_replace() 就无法处理太多边缘情况。 - DaveRandom
@DaveRandom 你很有可能是对的。但如果我们谈论模板系统,我会考虑面向对象的概念和 include() 引入模板文件。我在我的项目中有类似这样的东西: https://bitbucket.org/t1gor/strategy/src/4568202177890480e4cc0268b4458889bc6bf0ae/application/core/Template.php?at=default - t1gor

12

这里有一个便携式的解决方案,使用可变变量。耶!

$string = "I need to replace {X} and {Y}";
$X = 'something';
$Y = 'something else';

preg_match_all('/\{(.*?)\}/', $string, $matches);           

foreach ($matches[1] as $value)
{
    $string = str_replace('{'.$value.'}', ${$value}, $string);
}

首先设置您的字符串和替换内容。然后,执行正则表达式以获取匹配项数组(在 { 和 } 中的字符串,包括这些括号)。最后,循环遍历这些匹配项,并使用变量变量将其替换为上面创建的变量。太棒了!


虽然您已将其标记为正确选项,但我认为还有另一种选择。您不必使用变量变量,可以使用数组代替。

$map = array(
    'X' => 'something',
    'Y' => 'something else'
);

preg_match_all('/\{(.*?)\}/', $string, $matches);           

foreach ($matches[1] as $value)
{
    $string = str_replace('{'.$value.'}', $map[$value], $string);
}

这将允许您创建一个带有以下签名的函数:

public function parse($string, $map); // Probably what I'd do tbh

另一个选项是由toolmakersteve在评论中提出,它不需要循环并且使用strtr,但需要将变量稍作修改并使用单引号而非双引号:

$string = 'I need to replace {$X} and {$Y}';

$map = array(
    '{$X}' => 'something',
    '{$Y}' => 'something else'
);

$string = strtr($string, $map);

完美,谢谢!我会把这个封装成一个类/函数 :) 考虑到你正在使用 RE,我应该有任何性能方面的顾虑吗? - Dany Khalife
这是一个非常简单的正则表达式,我看不出有任何问题。如果你真的担心,可以运行一些性能测试,但微观优化没有意义 - 没问题的。 - Jimbo
非常感谢,关于变量作用域的观点也很好:D - Dany Khalife
是的,我删掉了那个 - 这已经足够好了 ;) 不想深入讨论 ;) - Jimbo
1
@zzzzBov 更新了另一个选项以防万一。显然,您仍需要添加检查以确保您要求的变量存在等等... - Jimbo
显示剩余6条评论

4

如果您正在运行5.4版本,并且关心能够在字符串中使用PHP内置的变量插值,那么您可以使用ClosurebindTo()方法,如下所示:

// Strings use interpolation, but have to return themselves from an anon func
$strings = [
    'en' => [
        'message_sent' => function() { return "You just sent a message to $this->recipient that said: $this->message."; }
    ],
    'es' => [
        'message_sent' => function() { return "Acabas de enviar un mensaje a $this->recipient que dijo: $this->message."; }
    ]
];

class LocalizationScope {
    private $data;

    public function __construct($data) {
        $this->data = $data;
    }

    public function __get($param) {
        if(isset($this->data[$param])) {
            return $this->data[$param];
        }

        return '';
    }
}

// Bind the string anon func to an object of the array data passed in and invoke (returns string)
function localize($stringCb, $data) {
    return $stringCb->bindTo(new LocalizationScope($data))->__invoke();
}

// Demo
foreach($strings as $str) {
    var_dump(localize($str['message_sent'], array(
        'recipient' => 'Jeff Atwood',
        'message' => 'The project should be done in 6 to 8 weeks.'
    )));
}

//string(93) "You just sent a message to Jeff Atwood that said: The project should be done in 6 to 8 weeks."
//string(95) "Acabas de enviar un mensaje a Jeff Atwood que dijo: The project should be done in 6 to 8 weeks."

(Codepad演示)

也许这种方法有些取巧,在这种情况下我并不喜欢使用$this。但您可以获得额外的好处,利用PHP的变量插值(允许您执行一些诸如转义之类难以通过正则表达式实现的操作)。


编辑: 添加了LocalizationScope ,另一个好处是:如果本地化匿名函数尝试访问未提供的数据,则不会发出警告。


1
很棒的答案!这正是我在寻找的,但不幸的是我正在运行5.3 :( 所以这就是为什么我不会选择你的答案的原因 :) - Dany Khalife
1
这实际上非常聪明(+1),但在我阅读了一分钟左右后才真正看出它在做什么(主要是因为你自己提到的$this问题)。基于非显而易见的行为,我会避免使用它,但同时这也是一个机械化的、非常灵活的解决问题的方案。 - DaveRandom
@DaveRandom 谢谢!我同意 $this 的问题。当我第一次编写代码时,我省略了它(假设更像 JavaScript 的作用域绑定),很快发现那样行不通。如果有一种方法可以延迟绑定 use 变量,这可能会更直观,但目前我完全同意 $this 不明显的问题。 - Bailey Parker

2

strtr 可能更适合这种情况,因为它会先替换最长的键:

$repls = array(
  'X' => 'Dany',
  'Y' => 'Stack Overflow',
);

foreach($data as $key => $value)
  $repls['{' . $key . '}'] = $value;

$result = strtr($text, $repls);

考虑一下你拥有像XX和X这样的密钥的情况。


如果您不想使用数组,而是从当前作用域公开所有变量:

$repls = get_defined_vars();

2
如果你在使用sprintf的时候唯一的问题就是参数顺序,那么你可以使用参数交换。
从文档中可以看到(http://php.net/manual/en/function.sprintf.php):
$format = 'The %2$s contains %1$d monkeys';
echo sprintf($format, $num, $location);

为什么这个被投票否决了?在我看来,答案显而易见。虽然语言的顺序可能会改变,但在编写模板字符串时,您知道它的顺序。使用 OP 示例 "%1$s 在 %2$s 上创建了一个主题" 或 "%2$s 由 %1$s 创建了一个新帖子" 等都支持 sprintf($template_string, 'Dany', 'StackOverflow')。这正是许多系统解决翻译问题的方法(例如 Wordpress 使用 gettext,并建议以这种方式通过 printf 传递您的 gettext 字符串,如果您需要字符串中的参数)。http://codex.wordpress.org/I18n_for_WordPress_Developers#Placeholders - Adam
好的。我看到原帖作者想要变量名完全相同。既然每个字符串都需要记录文档,我不确定是否值得这样做。由于他还说他考虑过sprintf但因为排序问题而放弃了,所以我认为这仍然是一个有效的答案。 - Adam

2

gettext是一个广泛使用的通用本地化系统,可以精确地实现你想要的功能。大多数编程语言都有相应的库,PHP内置了引擎。它由po文件驱动,是一种简单的基于文本的格式,有许多编辑器可供使用,并且与sprintf语法兼容。

它甚至具有处理某些语言中复杂复数形式的函数。

以下是一些示例。请注意,_()是gettext()的别名:

  • echo _('Hello world'); // 将以当前选择的语言输出hello world
  • echo sprintf(_("%s has created a thread on %s"), $name, $site); // 翻译字符串并将其传递给sprintf()
  • echo sprintf(_("%2$s has created a thread on %1$s"), $site, $name); // 与上述相同,但参数顺序已更改。

如果您有超过几个字符串,一定要使用现有的引擎,而不是编写自己的引擎。添加新语言只需翻译字符串列表,大多数专业翻译工具也可以使用此文件格式。

请查看维基百科和PHP文档,了解其基本概述:

谷歌可以找到大量文档,您喜欢的软件仓库很可能有一些用于管理po文件的工具。

我使用过的一些工具是:

  • poedit:非常轻巧简单。如果要翻译的内容不多,且不想花费时间思考如何操作,这个工具非常好用。
  • Virtaal:略微复杂,有一定的学习曲线,但也具有一些方便的功能,可以让您的生活更轻松。如果需要翻译大量内容,这个工具非常实用。
  • GlotPress是一个Web应用程序(来自WordPress人员),允许协作编辑翻译数据库文件。

gettext比xml更好吗?它会为每个msgid发送翻译请求到服务器吗? - Praveen D
不,它与 XML 没有任何共同之处。并且它在运行时不发送任何请求。它基本上是一组为每种语言设置的字符串集合。 - Phil

1
为什么不使用str_replace呢?如果你想把它作为模板。
echo str_replace(array('{X}', '{Y}'), array($X, $Y), $lang['example']);

如果您需要每次出现都执行此操作,那么 str_replace 就是为此而构建的。


我只是在寻找一个更便携的解决方案。 - Dany Khalife
“更具可移植性”是什么意思?str_replace在php运行的所有操作系统上都可以使用吗? - bumperbox
我不确定你所说的“更可移植”是什么意思? - Slobodan Antonijević
我的意思是它不需要在每次调用时传递所有3个参数,而只需取一个字符串即可。 - Dany Khalife

0

只是提供另一种使用关联数组的解决方案。这将循环遍历关联数组并替换模板或将其留空。

例如:

$list = array();
$list['X'] = 'Dany';
$list['Y'] = 'Stack Overflow';

$str = '{X} created a thread on {Y}';

$newstring = textReplaceContent($str,$list);


    function textReplaceContent($contents, $list) {


                while (list($key, $val) = each($list)) {
                    $key = "{" . $key . "}";
                    if ($val) {
                        $contents = str_replace($key, $val, $contents);
                    } else {
                        $contents = str_replace($key, "", $contents);
                    }
                }
                $final = preg_replace('/\[\w+\]/', '', $contents);

                return ($final);
            }

Zzzz Zzzzz Zzzzz zzZZzzzz :) - hakre

0

简单:

$X = 'Dany';
$Y = 'Stack Overflow';
$lang['example'] = "{$X} created a thread on {$Y}";

因此:
echo $lang['example'];

将输出:

Dany created a thread on Stack Overflow

根据您的要求。

更新:

根据原帖作者有关使解决方案更具可移植性的评论:

每次都让一个类来为您进行解析:

class MyParser {
  function parse($vstr) {
    return "{$x} created a thread on {$y}";
  }
}

这样,如果发生以下情况:

$X = 3;
$Y = 4;

$a = new MyParser();
$lang['example'] = $a->parse($X, $Y);

echo $lang['example'];

这将返回:

3 created a thread on 4;

再次确认一下:

$X = 'Steve';
$Y = 10.9;

$lang['example'] = $a->parse($X, $Y);

将会打印:

Steve created a thread on 10.9;

按要求。

更新2:

根据原始帖子中有关提高可移植性的评论:

class MyParser {
  function parse($vstr) {
    return "{$vstr}";
  }
}

$a = new MyParser();

$X = 3;
$Y = 4;
$vstr = "{$X} created a thread on {$Y}";

$a = new MyParser();
$lang['example'] = $a->parse($vstr);

echo $lang['example'];

将输出先前引用的结果。


请查看我对RiggsFolly答案的评论。 - Dany Khalife
有趣!不过,从我所看到的来看,这需要我为每个包含变量的模板创建一个方法,这是一个缺点。 - Dany Khalife
1
@DanyKhalife: 不一定。我已经更新了我的答案,以反映虽然我的实现是天真的,但您可以将其泛化。 - jrd1
是的,我明白你的意思 :) - Dany Khalife

0

尝试

$lang['example'] = "$X created a thread on $Y";

编辑:根据最新信息

也许你需要看一下sprintf()函数

然后你可以将模板字符串定义为这样

$template_string = '%s created a thread on %s';


$X = 'Fred';
$Y = 'Sunday';

echo sprintf( $template_string, $X, $Y );

$template_string 不会改变,但是在代码的后面当你给 $X$Y 分配不同的值时,你仍然可以使用 echo sprintf( $template_string, $X, $Y );

查看 PHP 手册


抱歉,我忘记在初始化此字符串时$X和$Y是未知的。 - Dany Khalife
为了更清晰,这基本上使用X和Y的当前值初始化字符串,但如果我想要重用此字符串模板,则不能... - Dany Khalife
你可以为 $_SESSION 变量设置一个条件吗? - verbumSapienti
它仍然无法解决问题。为了说明我的观点,假设 $X = 1, $Y = 2; echo $lang['example']; 然后 $X = 3, $Y = 4; echo $lang['example'];。这两个 echo 不应该相同,因为 $X 和 $Y 的值已经改变(这就是为什么我说在每个 echo 前可能需要调用一个函数来处理这个字符串)。 - Dany Khalife
感谢您的支持,但据我所知,sprintf要求变量按特定顺序排列,而在切换语言时我并不确定这一点 :) - Dany Khalife

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接