6

I have a linq query that does something simple like:

var k = people.Select(x=>new{x.ID, x.Name});

I then want a function or linq lambda, or something that will output the names in sentence format using commas and "ands".

{1, John}
{2, Mark}
{3, George}

to

"1:John, 2:Mark and 3:George"

I'm fine with hardcoding the ID + ":" + Name part, but it could be a ToString() depending on the type of the linq query result. I'm just wondering if there is a neat way to do this with linq or String.Format().

Ahmad Mageed
  • 94,561
  • 19
  • 163
  • 174
Harry
  • 113
  • 3
  • 6

17 Answers17

6

Why Linq?

StringBuilder sb = new StringBuilder();

for(int i=0;i<k.Count();i++)
{
   sb.Append(String.Format("{0}:{1}", k[i].ID, k[i].Name);
   if(i + 2 < k.Count())
      sb.Append(", ");
   else if(i + 1 < k.Count())
      sb.Append(" and ");
}

Really, all Linq will let you do is hide the loop.

Also, make sure you do or do not want the "Oxford Comma"; this algorithm will not insert one, but a small change will (append the comma and space after every element except the last, and also append "and " after the next-to-last).

Kyle
  • 4,261
  • 11
  • 46
  • 85
KeithS
  • 70,210
  • 21
  • 112
  • 164
  • I support this. The comparable LINQ is complicated for no reason. However, wouldn't it be better to do i – Rubys Sep 29 '10 at 17:16
  • 1
    It would probably be preferable to store the result of `k.Count()` in a local variable. – Timwi Sep 29 '10 at 17:17
  • @Rubys, Timwi: tom-ay-to, tom-ah-to. You'd also have to ToList() or ToArray() the results of the OP's query to index it, making the cardinality accessible through a member property Count or Length. As for efficiency, the else if is only evaluated if the first half is false, so it doesn't save much to take the last part out UNLESS the OP wants the Oxford comma. – KeithS Sep 29 '10 at 17:26
  • @KeithS: If tom-ay-to is O(n) and tom-ah-to is O(n²), I much prefer tom-ah-to. – Timwi Sep 29 '10 at 17:32
  • 3
    @Timwi: You prefer O(n²)? Odd. – MarkPflug Sep 29 '10 at 17:57
  • @Timwi: Disregarding your mistype, I mentioned that you'd have to ToList() the query results to index them; Therefore, the algorithm could use Count (which has constant-time evaluation) instead of Count() (which does have linear). I used Count() in my algorithm only because, not knowing whether a ToList() or ToArray() would be used, it would work for both. Besides, as Count() does not change and is not used outside the loop's scope, I wouldn't be surprised if the compiler optimized the Count() calls with a variable anyway. – KeithS Sep 29 '10 at 18:13
  • Count() does use the Count property of IList or the Length property of Array. k currently isn't either of those, but it's not hard to make it so. – Amy B Sep 29 '10 at 18:49
  • @KeithS: I agree with what you said except the last part. The C# compiler does not optimize *any* common subexpressions whatsoever. The .NET JIT compiler sometimes does, but definitely not method calls. – Timwi Sep 29 '10 at 22:28
  • "Why Linq?" "Really, all Linq will let you do is hide the loop." You appear to underestimate how much hiding the loop, or more accurately, replacing the loop with a call to a descriptively named function, improves readability. – jpmc26 Dec 20 '18 at 20:32
6
public string ToPrettyCommas<T>(
  List<T> source,
  Func<T, string> stringSelector
)
{
  int count = source.Count;

  Func<int, string> prefixSelector = x => 
    x == 0 ? "" :
    x == count - 1 ? " and " :
    ", ";

  StringBuilder sb = new StringBuilder();

  for(int i = 0; i < count; i++)
  {
    sb.Append(prefixSelector(i));
    sb.Append(stringSelector(source[i]));
  }

  string result = sb.ToString();
  return result;
}

Called with:

string result = ToPrettyCommas(people, p => p.ID.ToString() + ":" + p.Name);
Amy B
  • 108,202
  • 21
  • 135
  • 185
  • 1
    Ooh, I like your solution. It both carries elegance of functional languages and some pragmatic StringBuilder approach. Kudos. – Dan Abramov Sep 29 '10 at 20:05
3

Just for fun, here’s something that really uses functional LINQ — no loop and no StringBuilder. Of course, it’s pretty slow.

var list = new[] { new { ID = 1, Name = "John" },
                   new { ID = 2, Name = "Mark" },
                   new { ID = 3, Name = "George" } };

var resultAggr = list
    .Select(item => item.ID + ":" + item.Name)
    .Aggregate(new { Sofar = "", Next = (string) null },
               (agg, next) => new { Sofar = agg.Next == null ? "" :
                                            agg.Sofar == "" ? agg.Next :
                                            agg.Sofar + ", " + agg.Next,
                                    Next = next });
var result = resultAggr.Sofar == "" ? resultAggr.Next :
             resultAggr.Sofar + " and " + resultAggr.Next;

// Prints 1:John, 2:Mark and 3:George
Console.WriteLine(result);
Timwi
  • 65,159
  • 33
  • 165
  • 230
1

Much like the rest, this isn't better than using a string builder, but you can go (ignoring the ID, you can add it in):

IEnumerable<string> names = new[] { "Tom", "Dick", "Harry", "Abe", "Bill" };
int count = names.Count();
string s = String.Join(", ", names.Take(count - 2)
                 .Concat(new [] {String.Join(" and ", names.Skip(count - 2))}));

This approach pretty much abuses Skip and Take's ability to take negative numbers, and String.Join's willingness to take a single parameter, so it works for one, two or more strings.

Kobi
  • 135,331
  • 41
  • 252
  • 292
1

Using the Select operation that gives you an index, this can be written as a ONE LINE extension method:

public static string ToAndList<T>(this IEnumerable<T> list, Func<T, string> formatter)
{
   return string.Join(" ", list.Select((x, i) => formatter(x) + (i < list.Count() - 2 ? ", " : (i < list.Count() - 1 ? " and" : ""))));
}

e.g.

var list = new[] { new { ID = 1, Name = "John" },
                   new { ID = 2, Name = "Mark" },
                   new { ID = 3, Name = "George" } }.ToList();

Console.WriteLine(list.ToAndList(x => (x.ID + ": " + x.Name)));
Ian Mercer
  • 38,490
  • 8
  • 97
  • 133
0

Improving(hopefully) on KeithS's answer:

string nextBit = "";
var sb = new StringBuilder();
foreach(Person person in list)
{
    sb.Append(nextBit);
    sb.Append(", ");
    nextBit = String.Format("{0}:{1}", person.ID, person.Name);
}
sb.Remove(sb.Length - 3, 2);
sb.Append(" and ");
sb.Append(nextBit);
Timwi
  • 65,159
  • 33
  • 165
  • 230
Rubys
  • 3,167
  • 2
  • 25
  • 26
0

This is not pretty but will do the job using LINQ

string s = string.Join(",", k.TakeWhile(X => X != k.Last()).Select(X => X.Id + ":" + X.Name).ToArray()).TrimEnd(",".ToCharArray()) + " And " + k.Last().Id + ":" + k.Last().Name;
Viv
  • 2,515
  • 2
  • 22
  • 26
0

Y'all are making it too complicated:

var list = k.Select(x => x.ID + ":" + x.Name).ToList();
var str = list.LastOrDefault();
str = (list.Count >= 2 ? list[list.Count - 2] + " and " : null) + str;
str = string.Join(", ", list.Take(list.Count - 2).Concat(new[]{str}));
StriplingWarrior
  • 151,543
  • 27
  • 246
  • 315
0

StringBuilder Approach

Here's an Aggregate with a StringBuilder. There's some position determinations that are made to clean up the string and insert the "and" but it's all done at the StringBuilder level.

var people = new[]
{
    new { Id = 1, Name = "John" },
    new { Id = 2, Name = "Mark" },
    new { Id = 3, Name = "George" }
};

var sb = people.Aggregate(new StringBuilder(),
             (s, p) => s.AppendFormat("{0}:{1}, ", p.Id, p.Name));
sb.Remove(sb.Length - 2, 2); // remove the trailing comma and space

var last = people.Last();
// index to last comma (-2 accounts for ":" and space prior to last name)
int indexComma = sb.Length - last.Id.ToString().Length - last.Name.Length - 2;

sb.Remove(indexComma - 1, 1); // remove last comma between last 2 names
sb.Insert(indexComma, "and ");

// 1:John, 2:Mark and 3:George
Console.WriteLine(sb.ToString());

A String.Join approach could have been used instead but the "and" insertion and comma removal would generate ~2 new strings.


Regex Approach

Here's another approach using regex that is quite understandable (nothing too cryptic).

var people = new[]
{
    new { Id = 1, Name = "John" },
    new { Id = 2, Name = "Mark" },
    new { Id = 3, Name = "George" }
};
var joined = String.Join(", ", people.Select(p => p.Id + ":" + p.Name).ToArray());
Regex rx = new Regex(", ", RegexOptions.RightToLeft);
string result = rx.Replace(joined, " and ", 1); // make 1 replacement only
Console.WriteLine(result);

The pattern is simply ", ". The magic lies in the RegexOptions.RightToLeft which makes the match occur from the right and thereby makes the replacement occur at the last comma occurrence. There is no static Regex method that accepts the number of replacements with the RegexOptions, hence the instance usage.

Ahmad Mageed
  • 94,561
  • 19
  • 163
  • 174
0

This can be the way you can achieve your goal

var list = new[] { new { ID = 1, Name = "John" }, 
                   new { ID = 2, Name = "Mark" }, 
                   new { ID = 3, Name = "George" }
                 }.ToList();

int i = 0;

string str = string.Empty;

var k = list.Select(x => x.ID.ToString() + ":" + x.Name + ", ").ToList();

k.ForEach(a => { if (i < k.Count() - 1) { str = str +  a; } else { str = str.Substring(0, str.Length -2) + " and " + a.Replace("," , ""); } i++; });
Sany
  • 118
  • 1
  • 6
  • Close. This returns `, 1:John, 2:Mark and 3:George`. You need to check if it's the first element and not append the comma in that case. – Ahmad Mageed Sep 29 '10 at 18:52
0

How about this?

var k = people.Select(x=>new{x.ID, x.Name});
var stringified = people
                  .Select(x => string.Format("{0} : {1}", x.ID, x.Name))
                  .ToList();
return string.Join(", ", stringified.Take(stringified.Count-1).ToArray())
       + " and " + stringified.Last();
Niki
  • 15,662
  • 5
  • 48
  • 74
0

I have refined my previous answer and I believe this is the most elegant solution yet.
However it would only work on reference types that don't repeat in the collection (or else we'd have to use different means for finding out if item is first/last).

Enjoy!

var firstGuy = guys.First();
var lastGuy = guys.Last();

var getSeparator = (Func<Guy, string>)
    (guy => {
        if (guy == firstGuy) return "";
        if (guy == lastGuy) return " and ";
        return ", ";
    });

var formatGuy = (Func<Guy, string>)
    (g => string.Format("{0}:{1}", g.Id, g.Name));

// 1:John, 2:Mark and 3:George
var summary = guys.Aggregate("",
    (sum, guy) => sum + getSeparator(guy) + formatGuy(guy));
Dan Abramov
  • 264,556
  • 84
  • 409
  • 511
0

Here's a method that doesn't use LINQ, but is probably as efficient as you can get:

public static string Join<T>(this IEnumerable<T> list,
                             string joiner,
                             string lastJoiner = null)
{
    StringBuilder sb = new StringBuilder();
    string sep = null, lastItem = null;
    foreach (T item in list)
    {
        if (lastItem != null)
        {
            sb.Append(sep);
            sb.Append(lastItem);
            sep = joiner;
        }
        lastItem = item.ToString();
    }
    if (lastItem != null)
    {
        if (sep != null)
            sb.Append(lastJoiner ?? joiner);
        sb.Append(lastItem);
    }
    return sb.ToString();
}

Console.WriteLine(people.Select(x => x.ID + ":" + x.Name).Join(", ", " and "));

Since it never creates a list, looks at an element twice, or appends extra stuff to the StringBuilder, I don't think you can get more efficient. It also works for 0, 1, and 2 elements in the list (as well as more, obviously).

Gabe
  • 84,912
  • 12
  • 139
  • 238
0

Here's one using a slightly modified version of my answer to Eric Lippert's Challenge which is IMHO the most concise with easy to follow logic (if you're familiar with LINQ).

static string CommaQuibblingMod<T>(IEnumerable<T> items)
{
    int count = items.Count();
    var quibbled = items.Select((Item, index) => new { Item, Group = (count - index - 2) > 0})
                        .GroupBy(item => item.Group, item => item.Item)
                        .Select(g => g.Key
                            ? String.Join(", ", g)
                            : String.Join(" and ", g));
    return String.Join(", ", quibbled);  //removed braces
}

//usage
var items = k.Select(item => String.Format("{0}:{1}", item.ID, item.Name));
string formatted = CommaQuibblingMod(items);
Community
  • 1
  • 1
Jeff Mercado
  • 129,526
  • 32
  • 251
  • 272
0
static public void Linq1()
{
    var k = new[] { new[] { "1", "John" }, new[] { "2", "Mark" }, new[] { "3", "George" } };

    Func<string[], string> showPerson = p => p[0] + ": " + p[1];

    var res = k.Skip(1).Aggregate(new StringBuilder(showPerson(k.First())),
        (acc, next) => acc.Append(next == k.Last() ? " and " : ", ").Append(showPerson(next)));

    Console.WriteLine(res);
}

could be optimized by moving k.Last() computation to before the loop

Grozz
  • 8,317
  • 4
  • 38
  • 53
0
    public static string ToListingCommaFormat(this List<string> stringList)
    {
        switch(stringList.Count)
        {
            case 0:
                return "";
            case 1:
                return stringList[0];
            case 2:
                return stringList[0] + " and " + stringList[1];
            default:
                return String.Join(", ", stringList.GetRange(0, stringList.Count-1)) 
                    + ", and " + stringList[stringList.Count - 1];
        }
    }

This is the method is faster than the 'efficient' Join method posted by Gabe. For one and two items, it is many times faster, and for 5-6 strings, it is about 10% faster. There is no dependency on LINQ. String.Join is faster than StringBuilder for small arrays, which are typical for human-readable text. In grammar, these are called listing commas, and the last comma should always be included to avoid ambiguity. Here is the resulting code:

people.Select(x=> x.ID.ToString() + ":" + x.Name).ToList().ToListingCommaFormat();

humbads
  • 3,252
  • 1
  • 27
  • 22
-1

There are ways to optimize this since it isn't very efficient, but something like this may work:

var k = people.Select(x => new {x.ID, x.Name}).ToList();

var last = k.Last();
k.Aggregate(new StringBuilder(), (sentence, item) => { 
    if (sentence.Length > 0)
    {
        if (item == last)
            sentence.Append(" and ");
        else
            sentence.Append(", ");
    }

    sentence.Append(item.ID).Append(":").Append(item.Name);
    return sentence;
});
Matt H
  • 7,311
  • 5
  • 45
  • 54