23
\$\begingroup\$

Inspired by this question in chat

Is there an easy way in .NET to split a string into an array by pulling chunks of the text in. I.e. I have an 18 character string I would like in 3 6-character pieces.

I thought that ain't be that hard. I would like to get any thoughts and improvements you can think of.

public static string[] Split(this string value, int desiredLength, bool strict = false)
{

    EnsureValid(value, desiredLength, strict);

    if (value.Length == 0) { return new string[0]; }

    int numberOfItems = value.Length / desiredLength;

    int remaining = (value.Length > numberOfItems * desiredLength) ? 1 : 0;

    IList<string> splitted = new List<string>(numberOfItems + remaining);

    for (int i = 0; i < numberOfItems; i++)
    {
        splitted.Add(value.Substring(i * desiredLength, desiredLength));
    }

    if (remaining != 0)
    {
        splitted.Add(value.Substring(numberOfItems * desiredLength));
    }

    return splitted.ToArray();
}

private static void EnsureValid(string value, int desiredLength, bool strict)
{
    if (value == null) { throw new ArgumentNullException(nameof(value)); }

    if (value.Length == 0 && desiredLength != 0)
    {
        throw new ArgumentException($"The passed {nameof(value)} may not be empty if the {nameof(desiredLength)} <> 0");
    }

    if (value.Length != 0 && desiredLength < 1) { throw new ArgumentException($"The value of {nameof(desiredLength)} needs to be > 0"); }

    if (strict && (value.Length % desiredLength != 0))
    {
        throw new ArgumentException($"The passed {nameof(value)}'s length can't be split by the {nameof(desiredLength)}");
    }
}

I know the tests aren't complete and the naming could be better, like the missing of a test if the passed in value == null, but here they are for review as well

[TestMethod()]
public void SplitTest()
{
    string input = "123456";
    string[] expected = new string[] { "123", "456" };
    string[] actual = input.Split(3);

    CollectionAssert.AreEqual(expected, actual);
}

[TestMethod()]
public void SplitTestNotEqual()
{
    string input = "12345";
    string[] expected = new string[] { "123", "45" };
    string[] actual = input.Split(3);

    CollectionAssert.AreEqual(expected, actual);
}

[TestMethod()]
[ExpectedException(typeof(ArgumentException))]
public void SplitTestDesiredlengthIsZero()
{
    string input = "12345";
    string[] actual = input.Split(0);
}

[TestMethod()]
public void SplitTestDesiredlengthIsZeroWithEmptyString()
{
    string input = "";
    string[] expected = new string[0];
    string[] actual = input.Split(0);

    CollectionAssert.AreEqual(expected, actual);
}

[TestMethod()][ExpectedException(typeof(ArgumentException))]
public void SplitTestWillThrowBecauseOfStrict()
{
    string input = "12345";
    string[] actual = input.Split(3,true);
}
\$\endgroup\$
1

5 Answers 5

18
+100
\$\begingroup\$

Algorithm can't be improved (or at least I see no reason to do it, ignoring a minor micro-optimization about List<string> which usage, however, helps to keep code much more readable) and code is pretty clear, however:

  • You're returning string[] but consumers may not need it. List.ToArray() won't simply return internal array (because its capacity != size). It implies you will make another (relatively expansive) copy. I'd simply return IEnumerable<string>: if consumer needs an array it can call ToArray() LINQ extension method (which will/may check for underlying implementation), it he doesn't need an array then you saved an Array.Copy().
  • You're partitioning over Char but String is UTF-16 encoded then you may produce broken strings in, at least, three cases:

    1. One character is encoded with more than one code unit. Unicode code point for that character is encoded as two UTF-16 code units, each code unit may end up in two different slices (and both strings will be invalid).
    2. One character is composed by more than one code point. You're dealing with a character made by two separate Unicode code points (for example Han character 𠀑).
    3. One character has combining characters or modifiers. This is more common than you may think: for example Unicode combining character like U+0300 COMBINING GRAVE ACCENT used to build à and Unicode modifiers such as U+02BC MODIFIER LETTER APOSTROPHE.
  • Definition of character for a programming language and for a human being are pretty different, for example in Czech Slovak (thanks svick to correct me about this) is a single character however it's made by 2/3 Unicode code points which are in this case also 2/3 UTF-16 code units then "dž".Length > 1. More about this and other cultural issues on this Stack Overflow post.
  • Ligatures exist. Assuming one ligature is one code point (and also assuming it's encoded as one code unit) then you will treat it as a single glyph however it represents two characters. What to do in this case? In general definition of character may be pretty vague because it has a different meaning according to discipline where this word is used. You can't (probably) handle everything correctly but you should set some constraints and document code behavior.

To address all these issues (also third point if current culture is correctly set but not last one) I'd use:

string[] characters = StringInfo.GetTextElementEnumerator(value).ToArray();

And I'd perform subsequent processing over characters array instead of value. Note that this may lead to strings made of different length but with the same number of characters. For example (when performance are not an issue):

public static IEnumerable<string> Split(this string value, int desiredLength)
{
    var characters = StringInfo.GetTextElementEnumerator(value);
    while (characters.MoveNext())
        yield return String.Concat(Take(characters, desiredLength));
}

private static IEnumerable<string> Take(TextElementEnumerator enumerator, int count)
{
    for (int i = 0; i < count; ++i)
    {
        yield return (string)enumerator.Current;

        if (!enumerator.MoveNext())
            yield break;
    }
}

To do not reinvent the wheel you may just create your own AsEnumerable() extension method to walk through an existing enumerator:

public static IEnumerable<T> AsEnumerable<T>(this IEnumerator enumerator)
{
    while (enumerator.MoveNext())
        yield return (T)enumerator.Current;
}

Your code will be then simplified to:

public static IEnumerable<string> Split(this string value, int desiredLength)
{
    var characters = StringInfo.GetTextElementEnumerator(value);
    do
    {
        yield return String.Concat(characters.AsEnumerable<string>().Take(desiredLength));
    } while (characters.MoveNext());
}

If you don't need string[] return type you may live with IEnumerable<string> and use LINQ ToArray() when/if required (code is slightly simplified for demonstrative purposes, in real world you will need some error checking).

If you're processing US ASCII text and you won't need to partition horse d'œvre then you can simply ignore these issues.

\$\endgroup\$
6
  • \$\begingroup\$ Thanks a lot. The unicode part is quite enlightened. Do you maybe have some link(s) to read more about these kind of problems ? \$\endgroup\$
    – Heslacher
    Commented Nov 26, 2015 at 16:24
  • 1
    \$\begingroup\$ I wrote little bit about this on a Stack Overflow post: stackoverflow.com/a/27229590/1207195 but it's far to be complete (and should be improved a lot). It's a community wiki then feel free to add/edit/fix whatever you want. \$\endgroup\$ Commented Nov 26, 2015 at 16:30
  • \$\begingroup\$ Some clarifications from a Czech: is not a character in Czech (though it seems to be a character in Slovak). And it's written using two code points (d + ž), unless you use the aforementioned combining characters. \$\endgroup\$
    – svick
    Commented Nov 26, 2015 at 19:06
  • 2
    \$\begingroup\$ +1 - it's always nice to see someone else commenting about unicode. For the record, I always use the canonical form of e acute as my example: var eAcute = "e\u0301"; // é because it's a letter that is used in english, e.g. café, so more readily grokkable (for english speakers). \$\endgroup\$
    – RobH
    Commented Nov 27, 2015 at 8:47
  • 1
    \$\begingroup\$ @RobH I agree, I don't do it just because I got it on my keyboard but if those duplicates were dropped in favor of combining characters only then our life would be easier. I'm not sure (Heslacher may help us about this) but with German keyboards they may have both forms. \$\endgroup\$ Commented Nov 27, 2015 at 8:55
5
\$\begingroup\$

There are only two things I can suggest:

  1. You don't need to create a List<string> first and then copy it to an array via ToArray() extension method. You know an exact number of elements in the output, so it should be just an array string[].
  2. Since there are several methods named Split in the String class, it may be a little confusing. Consider to rename your method.
\$\endgroup\$
4
\$\begingroup\$

Although @AdrianoRepetti's answer is addressing some very important points, the suggested code

string[] characters = StringInfo.GetTextElementEnumerator(value).ToArray();   

just didn't happen to exists. Nevertheless his answer gave me a headstart for an answer to my question.

So I digged a little bit into the StringInfo class and discovered that there lives a very handy method, namely the SubstringByTextElements() method which seems to serve my purpose well.

In addition the LengthInTextElements property needs to replace the calls to the string.Length property to make the whole thing work.


By reading @Dmitri's answer I realized that he is right about

You know an exact number of elements in the output, so it should be just an array string[].

Unfortunately I can't agree about the naming, because just because the string class already has a Split() method I want to provide this as an overloaded one.


So after applying the mentioned points the methods will look like so

public static string[] Split(this string value, int desiredLength, bool strict = false)
{

    EnsureValid(value, desiredLength, strict);

    var stringInfo = new StringInfo(value);

    int currentLength = stringInfo.LengthInTextElements;
    if (currentLength == 0) { return new string[0]; }

    int numberOfItems = currentLength / desiredLength;

    int remaining = (currentLength > numberOfItems * desiredLength) ? 1 : 0;

    var chunks = new string[numberOfItems + remaining];

    for (var i = 0; i < numberOfItems; i++)
    {
        chunks[i] = stringInfo.SubstringByTextElements(i * desiredLength, desiredLength);
    }

    if (remaining != 0)
    {
        chunks[numberOfItems] = stringInfo.SubstringByTextElements(numberOfItems * desiredLength);
    }

    return chunks;
}
private static void EnsureValid(string value, int desiredLength, bool strict)
{
    if (value == null) { throw new ArgumentNullException(nameof(value)); }

    if (value.Length == 0 && desiredLength != 0)
    {
        throw new ArgumentException($"The passed {nameof(value)} may not be empty if the {nameof(desiredLength)} <> 0");
    }

    var info = new StringInfo(value);
    int valueLength = info.LengthInTextElements;

    if (valueLength != 0 && desiredLength < 1) { throw new ArgumentException($"The value of {nameof(desiredLength)} needs to be > 0"); }

    if (strict && (valueLength % desiredLength != 0))
    {
        throw new ArgumentException($"The passed {nameof(value)}'s length can't be split by the {nameof(desiredLength)}");
    }
}

which aren't only passing the former tests, but also this new tests

[TestMethod()]
public void SplitTestUnicodeVariant()
{
    string input = "𠀑𠀑𠀑a𠀑𠀑𠀑";
    string[] expected = new string[] { "𠀑𠀑", "𠀑a", "𠀑𠀑", "𠀑" };
    string[] actual = input.Split(2);
    CollectionAssert.AreEqual(expected, actual);
}
[TestMethod()]
public void SplitTestUnicodeVariant1()
{
    string input = "dž𠀑𠀑a𠀑é𠀑";
    string[] expected = new string[] { "dž𠀑", "𠀑a𠀑", "é𠀑" };
    string[] actual = input.Split(3);
    CollectionAssert.AreEqual(expected, actual);
}
[TestMethod()]
public void SplitTestUnicodeVariant2()
{
    string input = "éée\u0301éé";
    string[] expected = new string[] { "éé", "e\u0301é", "é" };
    string[] actual = input.Split(2);
    CollectionAssert.AreEqual(expected, actual);
}
\$\endgroup\$
2
  • \$\begingroup\$ @AdrianoRepetti the GetTextElementEnumerator() method exists, but there is no way to call ToArray() on the result because the result is a TextElementEnumerator on which one would need to call GetTextElement() prior to calling ToArray(). \$\endgroup\$
    – Heslacher
    Commented Nov 30, 2015 at 5:43
  • \$\begingroup\$ The unicode approach is overkill for ANSI encoded strings. I suggest to make 2 seperate implementations, a fast naive one for ANSI (most of time you'll need this one), and another one that supports Unicode. \$\endgroup\$
    – dfhwze
    Commented Jun 1, 2019 at 16:27
3
\$\begingroup\$

There are a few things that popped in my mind seeing this:

Generalization

The method can be used to split any enumerable/collection/array, but this is just a minor point.

EnsureValid

IMO this method does a little too much. It: checks if the parameters are valid (desiredLength != 0, which I think should also be desiredLength > 0 given the context) and also takes action to prevent the application from going on (throws exceptions, in this case). I'd change it into something like the following:

private static bool IsValid(string value, int desiredLength, bool strict)
{
    return value != null
        && !(value.Length == 0 && desiredLength != 0)
        && !(value.Length != 0 && desiredLength < 1)
        && !(strict && (value.Length % desiredLength != 0));
}

and let the management of special cases to the caller. In this way, the first line of the Split method becomes something like the following:

public static string[] Split(this string value, int desiredLength, bool strict = false)
{
    if(!IsValid(value, desiredLength, strict))
    {
        // manage the non valid case here
    }

    // other logic
}

Split method

In the current case, for strings I mean, the method seems good. In case you consider changing it for the general case, I'd suggest the following logic:

public static T[] Split<T>(this T[] value, int desiredLength, bool strict = false)
{
    if(!IsValid(value, desiredLength, strict))
    {
        // manage invalid case
    }

    var numberOfItems = (int)Math.Ceiling((double)value.Length / (double)desiredLength);
    var result = new T[numberOfItems];
    var chunkItems = new List<T>();

    for(int i = 0; i < numberOfItems; i++)
    {
        int chunkFirstIndex = i * desiredLength;

        for(int j = 0, currentIndex = chunkFirstIndex; 
            j < desiredLength && currentIndex < value.Length; 
            j++, currentIndex++)
        {
            chunkItems.Add(value[currentIndex]);
        }

        result[i] = chunkItems.ToArray();
        chunkItems.Clear();
    }

    return result;
}

Other than these things, everything else looks fine.

P.S: The code I wrote is not tested, it's there just to better explain the idea.

\$\endgroup\$
2
  • 3
    \$\begingroup\$ I don't agree with the IsValid() method. The EnsureValid() method does exactly what it should by ensuring that the passed arguments are valid. If they aren't valid the program flow is interupted by an exception. It can be seen like some Assert.() method. \$\endgroup\$
    – Heslacher
    Commented Nov 27, 2015 at 11:10
  • \$\begingroup\$ @Heslacher I see your point. I saw it more like an Assert.IsTrue(IsValid()) \$\endgroup\$ Commented Nov 28, 2015 at 9:20
2
\$\begingroup\$

I personally advise against designing a library function with the name of an existing framework function, but with a fundamentally different functionality. That only leads to confusion. I would call the function Crop because, well, it crops a string in evenly sized pieces.

Also implementing the functionality using an enumerator is ill advised, because we can calculate the exact amount of memory to allocate. Furthermore the function is so very light, that you'd be hard pressed to gain any advantage from an enumerator, even, if you only need the first element cropped. Since the object instantiation probably takes about the same time we need to finish, for smaller strings.

Below is my implementation for a tool library:

  • The implementation validates & counts the Unicode symbols in the string.
  • Iterates over all chars in one Unicode symbol at a time, and
  • memcpys the range of the string to the output array. This is done in the string ctor(char*,int,int) with Buffer.Memmove after the string allocation.
  • Counting the Unicode symbols in the validated string is done branchlessly.
  • !Warning: Only supports Unicode upto including version 4.1
  • !Important: When compiling with NET 5 or with this PR merged, my implementation of counting chars comprising a Unicode rune/symbol should be dropped in favor of StringInfo.GetNextTextElementLength
[Pure]
public static string[] Crop(this ReadOnlySpan<char> self, int chunkLength, bool strict)
{
    if (strict && self.Length % chunkLength != 0)
        throw new ArgumentOutOfRangeException(nameof(chunkLength));
    return Crop(self, chunkLength);
}

[Pure]
public static unsafe string[] Crop(this ReadOnlySpan<char> self, int chunkLength)
{
    int symbolCount = CountTextSymbols(self);
    if (symbolCount == 0)
    {
        if (chunkLength == 0)
            return new string[0];
        throw new ArgumentOutOfRangeException(nameof(self));
    }
    if (chunkLength > symbolCount || chunkLength <= 0)
        throw new ArgumentOutOfRangeException(nameof(chunkLength));
    if (symbolCount == chunkLength)
        return new []{ self.ToString() };

    string[] output = new string[(symbolCount - 1) / chunkLength + 1];
    fixed (char* ptr = &MemoryMarshal.GetReference(self))
    {
        if (symbolCount == self.Length)
        {
            CropAscii(ptr, symbolCount, chunkLength, output);
        }
        else
        {
            CropUnicode(ptr, symbolCount, chunkLength, output);   
        }
    }
    return output;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe void CropAscii(char* source, int symbolCount, int chunkLength, string[] output)
{
    int outIndex = 0;
    int i;
    for (i = 0; i + chunkLength <= symbolCount; i += chunkLength)
    {
        output[outIndex++] = new string(source, i, chunkLength);
    }

    if (symbolCount != i)
    {
        output[outIndex] = new string(source, i, symbolCount - i);
    }
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe void CropUnicode(char* source, int symbolCount, int chunkLength, string[] output)
{
    int outIndex = 0;
    int i = 0;
    int blockCharCount;
    do
    {
        blockCharCount = 0;
        int increment;
        for (int blockSymbols = 0; i < symbolCount && blockSymbols < chunkLength; i++, blockSymbols++, blockCharCount += increment)
        {
            increment = UnicodeSymbolCharLength(source + blockCharCount);
        }

        output[outIndex++] = new string(source, 0, blockCharCount);
        source += blockCharCount;
    } while (i < symbolCount);

    if (symbolCount != i)
    {
        source -= blockCharCount;
        blockCharCount = 0;
        while (*(source + blockCharCount) != 0)
            blockCharCount++;
        output[outIndex] = new string(source, 0, blockCharCount);
    }
}

public static int CountTextSymbols(this ReadOnlySpan<char> self) => CountTextSymbols(self, true);

public static int CountTextSymbols(this string? self, bool throwOnIllegalSymbol) => CountTextSymbols(self.AsSpan(), throwOnIllegalSymbol);

public static unsafe int CountTextSymbols(this ReadOnlySpan<char> self, bool throwOnIllegalSymbol)
{
    if (self.IsEmpty)
        return 0;
    int length;
    fixed (char* inPtr = &MemoryMarshal.GetReference(self))
    {
        length = CountValidTextSymbols(inPtr, self.Length);
    }

    if (length > 0)
        return length;

    length = ~length;
    if (throwOnIllegalSymbol)
        throw new ArgumentException(string.Format(ExceptionResource.UNICODE_ILLEGAL_SYMBOL, length), nameof(self));
    return length;
}

[Pure]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe int CountValidTextSymbols(char* c, int remainingChars)
{
    int increment;
    int length = 0;
    do
    {
        increment = ValidateUnicodeSymbol(c);
        if (increment > 0)
        {
            c += increment;
            length++;
        }
    } while (increment > 0);

    if (increment < 0)
    {
        return ~length;
    }

    return length;
}

/// <summary>Returns the number of 2byte characters that compose the next symbol. If the symbol is illegal returns the complement of the number of corrupted 2byte characters.</summary>
[Pure]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe int ValidateUnicodeSymbol(char* c)
{
    // Modified from source: https://stackoverflow.com/a/27084836/6401643

    uint ch = *c;
    // Null terminator -> return zero
    if (ch == 0)
        return 0;

    if (ch < 0xD800) // Char is up to first high surrogate
    {
        uint chLow = *(c + 1);

        if (chLow > 0x02F0 && chLow < 0x0370)
        {
            // Found combining -> check for 1byte in hi
            if (ch == 0)
            {
                return ~1;
            }

            if ((ch & 0xFF00) == 0)
            {
                // Found pair
                return 2;
            }
        }

        // Did not find pair, preserve combining character.
        return 1;
    }

    
    if (ch <= 0xDBFF)
    {
        uint chLow = *(c + 1);

        // Found high surrogate -> check surrogate pair
        if (chLow == 0)
        {
            // Fast char is high surrogate, so it is missing its pair
            return ~1;
        }

        if (!(chLow >= 0xDC00 && chLow <= 0xDFFF))
        {
            // Did not find a low surrogate after the high surrogate
            return ~1;
        }

        // Convert to UTF32 - like in Char.ConvertToUtf32(highSurrogate, lowSurrogate)
        ch = ((ch - 0xD800) << 10) + (chLow - 0xDC00) + 0x10000;
        if ((ch & 0xFFFE) == 0xFFFE) // Other non-char found
            return ~2;
        // Found a good surrogate pair
        return 2;
    }

    if (ch <= 0xDFFF) // Unexpected low surrogate
        return ~1;

    if (ch >= 0xFDD0 && ch <= 0xFDEF) // Non-chars are considered invalid by System.Text.Encoding.GetBytes() and String.Normalize()
        return ~1;

    if ((ch & 0xFFFE) == 0xFFFE) // Other non-char found
        return ~1;

    return 1;
}

/// <summary>
/// !Warning: Does not validate the symbol
/// !Warning: Assumes that the pointer does not point to the null terminator.
/// Returns the number of 2byte characters that compose the next symbol.
/// </summary>
[Pure]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static unsafe int UnicodeSymbolCharLength(char* c)
{
    // We can always check c and c + 1 (four bytes total) because of the null terminator.
    uint w32C = *(uint*)c;
    // Check whether we have a utf32 character
    bool isTwoChars = (w32C & 0xFC00FC00) == 0xDC00D800;
    // Check whether we have a 1byte character in lo and a combining 2byte character in hi
    isTwoChars |= ((w32C >> 16) > 0x02F0) & ((w32C >> 16) < 0x0370);
    return 1 + *(byte*)(&isTwoChars); // Casting bool to byte is 0 when false, 1 when true;
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.