在R中快速转义/反转义字符向量

10

为了在json中编码字符串,需要使用反斜杠转义几个保留字符,并将每个字符串用双引号括起来。目前,jsonlite包使用基本R中的deparse函数实现了这一点:

deparse_vector <- function(x) {
  stopifnot(is.character(x))
  vapply(x, deparse, character(1), USE.NAMES=FALSE)
}

这个可以解决问题:
test <- c("line\nline", "foo\\bar", "I said: \"hi!\"")
cat(deparse_vector(test))

然而,对于大向量来说,deparse 的速度较慢。另一种实现方法是逐个替换每个字符:gsub
deparse_vector2 <- function(x) {
  stopifnot(is.character(x))
  if(!length(x)) return(x)
  x <- gsub("\\", "\\\\", x, fixed=TRUE)
  x <- gsub("\"", "\\\"", x, fixed=TRUE)
  x <- gsub("\n", "\\n", x, fixed=TRUE)
  x <- gsub("\r", "\\r", x, fixed=TRUE)
  x <- gsub("\t", "\\t", x, fixed=TRUE)
  x <- gsub("\b", "\\b", x, fixed=TRUE)
  x <- gsub("\f", "\\f", x, fixed=TRUE)
  paste0("\"", x, "\"")
}

这样做会更快一些,但不太美观。有更好的方法吗?(最好不需要额外的依赖项)
可以使用此脚本来比较实现:链接
> system.time(out1 <- deparse_vector1(strings))
   user  system elapsed 
  6.517   0.000   6.523 
> system.time(out2 <- deparse_vector2(strings))
   user  system elapsed 
  1.194   0.000   1.194 

1
你能否至少发布一些你所做的时间记录,这样我们就知道什么是“足够快”? - MrFlick
我添加了一个链接来比较不同的实现方式。 - Jeroen Ooms
4个回答

7

这是温斯顿代码的C++版本。它相当简单,因为您可以有效地扩展 std::string。它也不太可能崩溃,因为 Rcpp 会为您处理内存管理。

#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
std::string escape_one(std::string x) {
  std::string out = "\"";

  int n = x.size();
  for (int i = 0; i < n; ++i) {
    char cur = x[i];

    switch(cur) {
      case '\\': out += "\\\\"; break;
      case '"':  out += "\\\""; break;
      case '\n': out += "\\n";  break;
      case '\r': out += "\\r";  break;
      case '\t': out += "\\t";  break;
      case '\b': out += "\\b";  break;
      case '\f': out += "\\f";  break;
      default:     out += cur;
    }
  }

  out += '"';

  return out;
}

// [[Rcpp::export]]
CharacterVector escape_chars(CharacterVector x) {
  int n = x.size();
  CharacterVector out(n);

  for (int i = 0; i < n; ++i) {
    String cur = x[i];
    out[i] = escape_one(cur);
  }

  return out;
}

在您的基准测试中,deparse_vector2(strings)花费了0.8秒,escape_chars(strings)花费了0.165秒。

1
小建议:在escape_one中使用out.reserve(n)为输出预先分配一些内存,以避免重新分配(我们知道out至少与x一样大)。 - Kevin Ushey
@KevinUshey 我在其他地方试过这个方法 - 它可以提高大约10%的性能。 - hadley

6
我不知道只用R代码有更快的方法,但我决定尝试用C实现它,包装在一个名为deparse_vector3的R函数中。它比较粗糙(而且我远非专业的C程序员),但似乎可以处理你的例子:https://gist.github.com/wch/e3ec5b20eb712f1b22b2 在我的系统上(Mac,R 3.1.1),deparse_vector2deparse_vector快了20倍以上,这比你测试时得到的5倍差距要大得多。
我的deparse_vector3函数只比deparse_vector2快3倍。可能还有改进的空间。
> system.time(out1 <- deparse_vector1(strings))
   user  system elapsed 
  8.459   0.009   8.470 
> system.time(out2 <- deparse_vector2(strings))
   user  system elapsed 
  0.368   0.007   0.374 
> system.time(out3 <- deparse_vector3(strings))
   user  system elapsed 
  0.120   0.001   0.120 

我认为这段代码可能无法正确处理非ASCII字符编码。以下是R源码中处理编码的示例:https://github.com/wch/r-source/blob/bfe73ecd848198cb9b68427cec7e70c40f96bd72/src/main/grep.c#L588-L630 编辑:这段代码似乎能够正确处理UTF-8,但在测试中我可能会漏掉某些情况。

我尝试在jsonlite中使用它,似乎可以运行,但是在一段时间后在R中会出现segfaults的问题... - Jeroen Ooms
我认为 val = mkChar(newstr) 需要改成 val = PROTECT(mkChar(newstr));(在 Free() 之后加上 unprotect)。 - hadley
@Jeroen,加上PROTECT后它是否可以工作? 我在R源代码中看到了很多mkChar而没有PROTECT,所以我认为它们不需要被保护,但我可能错了。 - wch
我找到了问题 - 我没有为字符串中的终止符\0分配足够的内存。再分配一个字节似乎可以解决它。我已经更新了代码片段。 - wch
嘿@wch,我认为行号已经过时了。我猜测了一系列合理的引用行,并在URL中添加了一个固定的提交引用。感谢您一如既往地维护GH镜像。 - MichaelChirico

4

您也可以尝试来自stringi包的stri_escape_unicode(虽然您更喜欢不需要额外依赖项的解决方案,但我认为它对未来的读者也可能有用),其速度比deparse_vector2快约3倍,比deparse_vector快约7倍。

require(stringi)

定义函数

deparse_vector3 <- function(x){
  paste0("\"",stri_escape_unicode(x), "\"")
}

检查所有函数是否给出相同的结果

all.equal(deparse_vector2(test), deparse_vector3(test))
## [1] TRUE
all.equal(deparse_vector(test), deparse_vector3(test))
## [1] TRUE

一些基准测试结果
library(microbenchmark)
microbenchmark(deparse_vector(test), 
               deparse_vector2(test),
               deparse_vector3(test), times = 1000L)

# Unit: microseconds
#                  expr    min      lq  median      uq      max neval
#  deparse_vector(test) 98.548 102.654 104.707 111.380 2500.653  1000
# deparse_vector2(test) 43.114  46.707  48.761  51.327  401.377  1000
# deparse_vector3(test) 14.885  16.938  18.991  20.018  240.211  1000 <-- Clear winner

3

这个问题还可以用一些技巧来解决。

给定长度为n的字符串x,我们知道输出字符串的长度至少为x,最多为2 * x。我们可以利用这一点来确保我们只分配一次内存,而不是依赖于容器增长(虽然效率很高)。

请注意,我在这里使用了C++11的shared_ptr,因为我正在使用原始内存进行丑陋的操作(并希望确保它自动清除)。这也允许我避免尝试计算匹配项的初始通行证,但也迫使我过度分配了一些内存(必须转义每个字符的情况将很少发生)。

我认为,将其适应于纯C解决方案相对容易,但要确保内存被正确清除可能会更困难。

#include <memory>
#include <Rcpp.h>
using namespace Rcpp;

// [[Rcpp::export]]
void escape_one_fill(CharacterVector const& x, int i, CharacterVector& output) {

  auto xi = CHAR(STRING_ELT(x, i));
  int n = strlen(xi);

  // Over-allocate memory -- we know that in the worst case the output
  // string is 2x the length of x (plus 1 for \0)
  auto out = std::make_shared<char*>(new char[n * 2 + 1]);

  int counter = 0;
  (*out)[counter++] = '"';

  #define HANDLE_CASE(X, Y) \
    case X: \
      (*out)[counter++] = '\\'; \
      (*out)[counter++] = Y; \
      break;

  for (int j = 0; j < n; ++j) {
    switch (xi[j]) {
      HANDLE_CASE('\\', '\\');
      HANDLE_CASE('"', '"');
      HANDLE_CASE('\n', 'n');
      HANDLE_CASE('\r', 'r');
      HANDLE_CASE('\t', 't');
      HANDLE_CASE('\b', 'b');
      HANDLE_CASE('\f', 'f');
      default: (*out)[counter++] = xi[j];
    }
  }

  (*out)[counter++] = '"';

  // Set a NUL so that Rf_mkChar does what it should
  (*out)[counter++] = '\0';
  SET_STRING_ELT(output, i, Rf_mkChar(*out));

}

// [[Rcpp::export]]
CharacterVector escape_chars_with_fill(CharacterVector x) {
  int n = x.size();
  CharacterVector out(n);

  for (int i = 0; i < n; ++i) {
    escape_one_fill(x, i, out);
  }

  return out;
}

基准测试的结果是,我得到了以下结果(只是与Hadley的实现进行比较):
> mychars <- c(letters, " ", '"', "\\", "\t", "\n", "\r", "'", "/", "#", "$");

> createstring <- function(length){
+   paste(mychars[ceiling(runif(length, 0, length(mychars)))], collapse="")
+ }

> strings <- vapply(rep(1000, 10000), createstring, character(1), USE.NAMES=FALSE)

> system.time(escape_chars(strings))
   user  system elapsed 
   0.14    0.00    0.14 

> system.time(escape_chars_with_fill(strings))
   user  system elapsed 
  0.080   0.001   0.081 

> identical(escape_chars(strings), escape_chars_with_fill(strings))
[1] TRUE

虽然我不认为system.time是比较方法的正确方式,特别是当差异如此之小时。 - David Arenburg

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