【问题标题】:Can you change the contents of a (immutable) string via an unsafe method?您可以通过不安全的方法更改(不可变)字符串的内容吗?
【发布时间】:2015-09-08 18:37:08
【问题描述】:

我知道字符串是不可变的,对字符串的任何更改只会在内存中创建一个新字符串(并将旧字符串标记为空闲)。但是,我想知道我下面的逻辑是否合理,因为您实际上可以以循环方式修改字符串的内容。

const string baseString = "The quick brown fox jumps over the lazy dog!";

//initialize a new string
string candidateString = new string('\0', baseString.Length);

//Pin the string
GCHandle gcHandle = GCHandle.Alloc(candidateString, GCHandleType.Pinned);

//Copy the contents of the base string to the candidate string
unsafe
{
    char* cCandidateString = (char*) gcHandle.AddrOfPinnedObject();
    for (int i = 0; i < baseString.Length; i++)
    {
        cCandidateString[i] = baseString[i];
    }
}

这种方法确实改变了内容candidateString(没有在内存中创建新的candidateString)还是运行时看穿了我的技巧并将其视为普通字符串?

【问题讨论】:

  • 运行代码并亲自了解会发生什么。
  • 因为我只能看到最终结果,而看不到内存中发生的事情。
  • cCandidateString[i] = baseString[i] 行放置一个断点,并观察candidateString 在每次迭代中的变化。有趣的东西!
  • 是的,没问题。你知道,不安全的代码是不安全的。它也不需要 unsafe 关键字,摸索 [DllImport] 并在需要 StringBuilder 的地方声明字符串也可以完成。当你传递一个字符串文字时非常有趣。曾经甚至可以使用 Reflection 更改 String.Empty,但他们阻止了这一点。

标签: c#


【解决方案1】:

您的示例运行良好,这要归功于以下几个因素:

  • candidateString 位于托管堆中,因此可以安全地进行修改。将此与已实习的baseString 进行比较。如果您尝试修改被实习的字符串,可能会发生意想不到的事情。不能保证字符串在某些时候不会存在于写保护的内存中,尽管它现在似乎可以工作。这与将常量字符串分配给 C 中的 char* 变量然后修改它非常相似。在 C 中,这是未定义的行为。

  • 您在 candidateString 中预先分配了足够的空间 - 这样您就不会溢出缓冲区。

  • 字符数据存储在String 类的偏移量0 处。它存储在等于RuntimeHelpers.OffsetToStringData 的偏移量处。

    public static int OffsetToStringData
    {
        // This offset is baked in by string indexer intrinsic, so there is no harm
        // in getting it baked in here as well.
        [System.Runtime.Versioning.NonVersionable] 
        get {
            // Number of bytes from the address pointed to by a reference to
            // a String to the first 16-bit character in the String.  Skip 
            // over the MethodTable pointer, & String 
            // length.  Of course, the String reference points to the memory 
            // after the sync block, so don't count that.  
            // This property allows C#'s fixed statement to work on Strings.
            // On 64 bit platforms, this should be 12 (8+4) and on 32 bit 8 (4+4).
    #if WIN32
            return 8;
    #else
            return 12;
    #endif // WIN32
        }
    }
    

    除了...

  • GCHandle.AddrOfPinnedObject 对于两种类型是特殊情况string 和数组类型。它不是返回对象本身的地址,而是返回数据的偏移量。请参阅 CoreCLR 中的 source code

    // Get the address of a pinned object referenced by the supplied pinned
    // handle.  This routine assumes the handle is pinned and does not check.
    FCIMPL1(LPVOID, MarshalNative::GCHandleInternalAddrOfPinnedObject, OBJECTHANDLE handle)
    {
        FCALL_CONTRACT;
    
        LPVOID p;
        OBJECTREF objRef = ObjectFromHandle(handle);
    
        if (objRef == NULL)
        {
            p = NULL;
        }
        else
        {
            // Get the interior pointer for the supported pinned types.
            if (objRef->GetMethodTable() == g_pStringClass)
                p = ((*(StringObject **)&objRef))->GetBuffer();
            else if (objRef->GetMethodTable()->IsArray())
                p = (*((ArrayBase**)&objRef))->GetDataPtr();
            else
                p = objRef->GetData();
        }
    
        return p;
    }
    FCIMPLEND
    

总之,运行时让您可以使用它的数据而不会抱怨。毕竟您使用的是unsafe 代码。我见过比这更糟糕的运行时混乱,包括在堆栈上创建引用类型;-)

如果您的最终字符串比分配的字符串短,请记住在所有字符(偏移量Length)之后添加一个额外的\0。这不会溢出,每个字符串末尾都有一个隐含的空字符以简化互操作场景。


现在看看StringBuilder是如何创建字符串的,这里是StringBuilder.ToString

[System.Security.SecuritySafeCritical]  // auto-generated
public override String ToString() {
    Contract.Ensures(Contract.Result<String>() != null);

    VerifyClassInvariant();

    if (Length == 0)
        return String.Empty;

    string ret = string.FastAllocateString(Length);
    StringBuilder chunk = this;
    unsafe {
        fixed (char* destinationPtr = ret)
        {
            do
            {
                if (chunk.m_ChunkLength > 0)
                {
                    // Copy these into local variables so that they are stable even in the presence of race conditions
                    char[] sourceArray = chunk.m_ChunkChars;
                    int chunkOffset = chunk.m_ChunkOffset;
                    int chunkLength = chunk.m_ChunkLength;

                    // Check that we will not overrun our boundaries. 
                    if ((uint)(chunkLength + chunkOffset) <= ret.Length && (uint)chunkLength <= (uint)sourceArray.Length)
                    {
                        fixed (char* sourcePtr = sourceArray)
                            string.wstrcpy(destinationPtr + chunkOffset, sourcePtr, chunkLength);
                    }
                    else
                    {
                        throw new ArgumentOutOfRangeException("chunkLength", Environment.GetResourceString("ArgumentOutOfRange_Index"));
                    }
                }
                chunk = chunk.m_ChunkPrevious;
            } while (chunk != null);
        }
    }
    return ret;
}

是的,它使用了不安全的代码,是的,您可以使用fixed 优化您的代码,因为这种类型的固定比分配 GC 句柄要轻得多

const string baseString = "The quick brown fox jumps over the lazy dog!";

//initialize a new string
string candidateString = new string('\0', baseString.Length);

//Copy the contents of the base string to the candidate string
unsafe
{
    fixed (char* cCandidateString = candidateString)
    {
        for (int i = 0; i < baseString.Length; i++)
            cCandidateString[i] = baseString[i];
    }
}

当您使用fixed 时,GC 只会在收集过程中偶然发现一个需要固定的对象时才发现它。如果没有进行收集,则甚至不涉及 GC。使用GCHandle时,每次都会在GC中注册一个句柄。

【讨论】:

  • 实习字符串不也存在于托管堆中吗?当然修改一个实习字符串会很糟糕,因为该字符串的其他用户很可能也有对它的引用,它会更改字符串的哈希码等,但我认为这完全是数据结构而不是对象的问题存放地点。
  • @Random832 是的,但我的意思是它不太安全,因为运行时将来可能将它们存储在写保护的内存中(例如内存它们来自的映射的 exe 图像)。今天它似乎没有写保护,但没有什么能保证它会永远保持这种状态。
【解决方案2】:

正如其他人所指出的,在极少数情况下,改变 String 对象很有用。我举一个例子,下面有一个有用的代码sn-p。

用例/背景

虽然每个人都应该是 .NET 一直提供的真正出色的字符编码支持的忠实拥护者,但有时最好减少这种开销,尤其是在之间进行大量往返时8 位(旧版)字符和托管字符串(即典型的互操作场景)。

正如我所暗示的,.NET 特别强调您必须明确指定文本 Encoding 用于非 Unicode 字符数据与托管 String 对象之间的任何/所有转换。这种对外围的严格控制确实值得称道,因为它确保一旦您在托管运行时中拥有字符串,您就不必担心; 一切 只是宽 Unicode。甚至 UTF-8 在这个原始领域也基本上被淘汰了。

(相比之下,回想一下另一种流行的脚本语言,它在整个领域都出了名的拙劣,最终导致了几个 并行的 2.x3.x 版本,所有这些都是由于广泛的 Unicode 变化后者。)

所以.NET 将所有混乱推到互操作边界,一旦你进入内部就强制执行 Unicode (UTF-16),但这种理念需要完成编码/解码工作(“一劳永逸” ) 是详尽无遗的,因此 .NET 编码/编码器类可能是性能瓶颈。如果您将大量文本从宽 (Unicode) 移动到简单的固定 7 位或 8 位窄 ANSI、ASCII 等(请注意,我不是在谈论 MBCS 或 UTF-8,您将要在其中使用编码器!),.NET 编码范式可能看起来有点矫枉过正。

此外,您可能不知道或不关心指定Encoding。也许您所关心的只是对 16 位 Char 的低字节进行快速准确的往返。如果你look at the .NET source code,在某些情况下,即使是System.Text.ASCIIEncoding 也可能过于庞大。


代码 sn-p...

细字符串: 8 位字符直接存储在托管的 字符串,每个宽 Unicode 字符一个“细字符”,没有 在往返期间为字符编码/解码而烦恼。

所有这些方法都只是忽略/剥离每个 16 位 Unicode 字符的高字节,仅按原样传输每个低字节。显然,只有在那些高位不相关的情况下,才能在往返后成功恢复 Unicode 文本。

/// <summary> Convert byte array to "thin string" </summary>
public static unsafe String ToThinString(this byte[] src)
{
    int c;
    var ret = String.Empty;
    if ((c = src.Length) > 0)
        fixed (char* dst = (ret = new String('\0', c)))
            do
                dst[--c] = (char)src[c];  // fill new String by in-situ mutation
            while (c > 0);

    return ret;
}

在刚刚显示的方向上,通常将原生数据 in 引入托管,您通常没有托管字节数组,因此与其分配一个临时的仅出于调用此函数的目的,您可以将原始本机字节直接处理为托管字符串。和以前一样,这会绕过所有字符编码。

为了清楚起见,省略了这个不安全函数中需要的(明显的)范围检查:

public static unsafe String ToThinString(byte* pSrc, int c)
{
    var ret = String.Empty;
    if (c > 0)
        fixed (char* dst = (ret = new String('\0', c)))
            do
                dst[--c] = (char)pSrc[c];  // fill new String by in-situ mutation
            while (c > 0);

    return ret;
}

String 突变的优点是您可以通过直接写入最终分配来避免临时分配。即使您要通过使用stackalloc 来避免额外分配,当您最终调用String(Char*, int, int) 构造函数时,也会有不必要的重新复制整个事情:显然没有办法将您费力准备的数据与String 在你完成之前不存在的对象!


为了完整性...

这是镜像代码,它反转操作以返回一个字节数组(即使这个方向没有碰巧说明字符串变异技术)。这是您通常用于发送托管 .NET 运行时的 Unicode 文本输出 以供旧版应用使用的方向。

/// <summary> Convert "thin string" to byte array </summary>
public static unsafe byte[] ToByteArr(this String src)
{
    int c;
    byte[] ret = null;
    if ((c = src.Length) > 0)
        fixed (byte* dst = (ret = new byte[c]))
            do
                dst[--c] = (byte)src[c];
            while (c > 0);

    return ret ?? new byte[0];
}

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-03-03
    • 2013-01-08
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多