Спор "Java vs. C#" существует чуть меньше, чем вечность. Есть много статей, затрагивающих разные участки его спектра: Что есть в C# чего нет в Java, что языки друг у друга позаимствовали, у одних LINQ, у других обратная совместимость, в общем, тысячи их.

Однако, я никогда не видел, чтобы писали о чём-то, что в Java, с точки зрения фич языка есть, чего в C# нет. Впрочем, я здесь не для того, чтобы спорить. Эта статья призвана выразить моё субъективное мнение и заполнить небольшой пробел по теме, озвученной в заголовке.


Оба языка крутые, востребованные, у меня нет цели принизить один на фоне другого. Наоборот, хочу озвучить, что можно было бы, на мой взгляд, привнести, и порассуждать о том, насколько это нужно. Поэтому перейдём сразу к списку.

1. Class based Enum

Ни для кого не секрет, что в отличие от Java, в C# и C++ перечисления это именованные числовые константы. А что есть перечисления в Java? По сути, синтаксический сахар поверх класса. Напишем какое-нибудь перечисление, например. для хранения типов "слов", распознаваемых лексическим анализатором:

enum TokenType {
    IDENTIFIER,
    NUMBER,
    ASSIGN;
}

И поскольку перечисление это тот же класс, то можно накрутить конструктор, методы, поля, да даже реализацию интерфейса! Добавим возможность константам перечисления трансформироваться в регулярное выражение:

interface ToPattern {
    Pattern getPattern();
}

enum TokenType implements ToPattern {
    IDENTIFIER("[a-zA-Z][a-zA-Z0-9]*"),
    NUMBER("[0-9]+"),
    ASSIGN("[=]");
    
    private final String pattern;
    
    private TokenType(String pattern){
        this.pattern = pattern;
    }

    @Override
    public Pattern getPattern() {
        return Pattern.compile(pattern);
    }
}

А как сделать подобное в C#? Есть два варианта:

  1. Атрибуты и методы расширений с рефлексией (нельзя реализовывать интерфейсы):

[AttributeUsage(AttributeTargets.Field)]
internal class PatternAttribute : Attribute
{
    public string Pattern { get; }

    public PatternAttribute(string pattern) => 
        Pattern = pattern;
}

public enum TokenType
{
    [Pattern("[a-zA-Z][a-zA-Z0-9]*")] Identifier,
    [Pattern("[0-9]+")] Number,
    [Pattern("[=]")] Assign
}

public static class TokenTypeExtensions
{
    public static Regex GetRegex(this TokenType tokenType) =>
        new(typeof(TokenType)
            .GetField(tokenType.ToString())!
            .GetCustomAttribute<PatternAttribute>()!
            .Pattern);
}
  1. Классы с публичными статическими константами:

interface IHasRegex
{
    Regex Regex { get; }
}

class TokenType : IHasRegex
{
    public static readonly TokenType Identifier =
        new("[a-zA-Z][a-zA-Z0-9]*");
    public static readonly TokenType Number =
        new("[0-9]+");
    public static readonly TokenType Assign =
        new("[=]");

    private readonly string _pattern;

    private TokenType(string pattern) =>
        _pattern = pattern;
    
    public Regex Regex => new(_pattern);
}

Напрашивается вопрос:

Зачем мне перечисления в C#, если я могу реализовывать их так, как они устроены в Java?

Особенно актуальный при наличии новых возможностей языка в последних версиях относительно ключевого слова switch.

2. Full support of covariant return types

Начиная с C# 9, в языке появилась возможность делать возвращаемые типы методов ковариантными. Если раньше код писался примерно так:

abstract record Fruit;

record Apple : Fruit;

record Orange : Fruit;

abstract class FruitFactory<TFruit>
    where TFruit : Fruit
{
    public abstract TFruit Create();
}

class AppleFactory : FruitFactory<Apple>
{
    public override Apple Create() => new();
}

class OrangeFactory : FruitFactory<Orange>
{
    public override Orange Create() => new();
}

То сейчас лишние конструкции можно опустить:

abstract class FruitFactory
{
    public abstract Fruit Create();
}

class AppleFactory : FruitFactory
{
    public override Apple Create() => new();
}

class OrangeFactory : FruitFactory
{
    public override Orange Create() => new();
}

В Java это было почти всегда, и сама возможность работала чуть шире. Она распространялась на реализацию и расширение интерфейсов. Например, я описываю структуру, которую можно копировать вместе данными. Для этого мне нужно указать, что данные копируются. За это отвечает контракт Cloneable. По умолчанию, метод clone возвращает Object. Однако, чтобы не засорять код кастами, я могу написать, что clone возвращает то, что копируется:

class Tree<T> implements Cloneable {
    private final Node<T> root;

    public Tree(Node<T> root) {
        this.root = root;
    }

    @Override
    public Tree<T> clone() throws CloneNotSupportedException {
        super.clone();
        return new Tree<>(root.clone());
    }
}

class Node<T> implements Iterable<Node<T>>, Cloneable {
    private final T data;
    private final List<Node<T>> children;

    public Node(T data) {
        this.data = data;
        children = new ArrayList<>();
    }

    private void push(Node<T> node) {
        children.add(node);
    }

    @Override
    public Iterator<Node<T>> iterator() {
        return new ArrayList<>(children).iterator();
    }

    @Override
    public Node<T> clone() throws CloneNotSupportedException {
        super.clone();
        var node = new Node<>(data);
        for (var child : this) {
            node.push(child.clone());
        }
        return node;
    }
}

В C# так сделать нельзя, выйдет ошибка:

Method 'Clone' cannot implement method from interface 'System.ICloneable'. Return type should be 'object'.

class Foo : ICloneable
{
    public Foo Clone()
    {
        throw new NotImplementedException();
    }
}

Почему у интерфейсов ещё нет ковариантности возвращаемого типа - вопрос открытый, даже в спецификации языка.

3. Functional Interfaces

В Java есть понятие функциональный интерфейс. Функциональный интерфейс (functional interface) – интерфейс с единственным абстрактным методом. Основная фишка таких интерфейсов в том, что их экземпляры можно инициализировать с помощью лямбда выражений (начиная с Java 8):

@FunctionalInterface
interface IntegerBinaryExpression {
    int evaluate(int a, int b);
}

// ...

IntegerBinaryExpression add = (a, b) -> a + b;
System.out.println(add.evaluate(3, 5)); // 8

Однако, о том, почему именно так всё устроено, нетрудно догадаться, если посмотреть, на что предлагает заменить IDE значение, присваиваемое переменной add типа IntegerBinaryExpression:

IntelliJ IDEA
IntelliJ IDEA

Если нажать на предлагаемый replace, то получим:

IntegerBinaryExpression add = Integer::sum;

Всё это, вместе с синтаксисом "пуговицы" (::), говорит об одном: функциональные интерфейсы - всего лишь механизм реализации callback'ов в Java. В C# есть делегаты, поэтому надобность в подобном сахаре крайне сомнительна, хоть и выглядит удобно, особенно для интерфейсов, экземпляры которых используются в проекте единожды.

4. Anonymous interface implementation

Предыдущий пример, возможно, стоило рассмотреть именно в этой секции, поскольку он является демонстрацией частного случая крутой, на мой взгляд, фичи Java - анонимная реализация интерфейсов.

Возьмём теперь контракт, у которого не меньше двух методов:

interface Pair<F, S> {
    F first();

    S second();
}

И если начать набирать new Pair для создания экземпляра интерфейса, то нам не выскочит ошибка о том, что нельзя создавать инстансы абстрактных сущностей, а предложение реализовать методы:

var myPair = new Pair<String, Integer>() {
    @Override
    public String first() {
        return "first";
    }

    @Override
    public Integer second() {
        return 2;
    }
};

Также такие штуки можно проворачивать и с классами (абстрактными и не очень):

class Book {
    public void read() {
        // ...
    }
}

// ...

var myBook = new Book() {
    @Override
    public void read() {
        super.read();
    }
};

Эта фича открывает новые возможности для создания программного обеспечения в случаях, когда надо не раздувать структуру проекта и на лету создавать новые реализации контрактов, или необходимо инкапсулировать какие-то специфичные сценарии использования контракта. Безусловно, жду в C#, все возможности у CLR для этого есть. В репозитории Roslyn даже есть feature request.

Заключение

Поделился с Вами о своих взглядах о возможных направлениях развития языка программирования C# и освятил, ранее не тронутую тему, о том, чего в C# нет, что в Java есть. Надеюсь, было интересно и полезно! Спасибо, что прочитали!


Ещё я веду telegram канал StepOne, где оставляю много интересных заметок про коммерческую разработку и мир IT глазами эксперта.

Комментарии (48)


  1. alexeishch
    12.09.2022 12:11
    +28

    1 - Enum должны быть быстрыми. Они для этого и целочисленные

    2 - В Java Generic это синтаксический сахар языка, с точки зрения виртуальной машины IList<ClassA> и IList<ClassB> это одно и то же из-за Type Erasure (https://docs.oracle.com/javase/tutorial/java/generics/erasure.html). В .NET Generic реализованы на уровне виртуальной машины специальными опкодами.

    3 - они .NET 7 появятся

    4 - в C# для этих целей сделаны делегаты

    Ну т.е. не нужно на C# писать также как пишут на Java, это отдельный язык и несмотря на его похожесть - это всё же не то же самое


    1. ris58h
      12.09.2022 12:18
      +5

      1 - Enum должны быть быстрыми. Они для этого и целочисленные

      Вы это так формулируете, что может показаться, что в Java enum не быстрый. Что вы, кстати, вкладываете в это понятие?


      1. sla000
        12.09.2022 13:51
        +2

        Возможно тут речь про то что существует полно статей в которых не рекомендуется использовать enum под Android, дескать размер программы больше в разы а расход памяти больше на порядок по сравнению с public static final int


      1. ryzhehvost
        12.09.2022 13:52
        -4

        В Java всё не быстрое...


      1. andreyverbin
        13.09.2022 01:56

        Дело не в скорости, а в p/Invoke. Изначально enum делался совместимым с C enum


        1. a-tk
          13.09.2022 08:15

          Строго говоря нет причин не сделать как с потомками WaitHandle: при p/invoke они передаются как инкапсулируемые хэндлы.


  1. AxisPod
    12.09.2022 12:18
    +2

    1. Решение сам и написал. Перечисления в C# - это именно классические перечисления, просто константы на уровне компилятора. В Java это совершенно иная конструкция. Использование enum в Java стиле спорная вещь, по сути это перечисление, что возвращает данные совершенно иного типа. Ну и как бы слегка нарушает принцип единой ответственности.

    2. Ковариантность - это такой неплохой способ стрелять себе в ногу. Зачем? Пример приведён чисто академический. В реальной жизни ООП на таком уровне где-то используется? Лучше уж жить на фабриках и тогда ковариантность вообще не потребуется.

    1. Смотри что такое delegate в C#

    2. Собственно мне наоборот не нравится данная фича. Так как олпять же вносит энтропию в проект. Да, слегка уменьшает объёмы кода, но при этом в Java есть куча всего, что прям увеличивает объёмы кода в неимоверных масштабах. Ну и опять же, делегаты убирают кучу необходимых мест для использования анонимных реализаций интерфейсов.


    1. Stefanio Автор
      12.09.2022 12:23
      +1

      Про делегаты написал ещё в статье, что их существование аннулирует необходимость функциональных интерфейсов:

      В C# есть делегаты, поэтому надобность в подобном сахаре крайне сомнительна


    1. Maccimo
      12.09.2022 15:38
      +5

      Перечисления в C# — это именно классические перечисления, просто константы на уровне компилятора. В Java это совершенно иная конструкция. Использование enum в Java стиле спорная вещь, по сути это перечисление, что возвращает данные совершенно иного типа.

      Дланьчело.
      Перечисление это математическая абстракция, представление оного в виде числа — не более, чем его грубое приближение из-за ограниченности ресурсов на заре компьютеризации, а вовсе не "классика".


      An enumeration is a complete, ordered listing of all the items in a collection. The term is commonly used in mathematics and computer science to refer to a listing of all of the elements of a set.

      https://en.wikipedia.org/wiki/Enumeration


      Никаких ограничений на внутреннюю структуру элементов перечисления вышеупомянутая абстракция не налагает.


  1. fransua
    12.09.2022 12:41
    -4

    Не хватает возможности указать исключения, которые может бросать метод.


    1. Neki
      12.09.2022 12:51
      +12

      Ну вроде все нормальные языки стараются избавиться от проверяемых исключений) А для непроверяемых это делать крайне странно IMHO


      1. amarkevich
        12.09.2022 23:11

        не избавиться вообще, а оставить проверяемые там, где это полезно. наблюдать исключение в рантайме - такое себе занятие, в сравнении с необходимостью обработки на этапе написания кода.


        1. Neki
          12.09.2022 23:38

          Ну вот например kotlin, почитайте там целая глава почему они отказались от проверяемых исключений. https://kotlinlang.ru/docs/exceptions.html

          В swift, typescript тоже обошлись без них


      1. FruTb
        13.09.2022 01:37
        +1

        Тут вся школа ФП-на-эффектах икнула. ZIO/CATS на скале к примеру.

        Причем фоннейман - это лямбда-счисление которые доказывается. А тьюринг - недоказуемая модель


    1. Stefanio Автор
      12.09.2022 12:55
      +4

      Да, компилятор конечно не заставишь ругаться, но можно использовать xmldoc тег <exception>
      https://docs.microsoft.com/ru-ru/dotnet/csharp/language-reference/xmldoc/recommended-tags#exception


      1. FruTb
        13.09.2022 01:43

        А почему это нельзя выразить типами? Отвратительный опыт когда твой код после компилятора ломается в проде


        1. a-tk
          13.09.2022 08:18
          +1

          Как только начинается вызов других апишек, наступает комбинаторный взрыв того, что может отстрелить.


  1. a-tk
    12.09.2022 12:59

    По первому пункту посмотрите на Discriminated Unions в F#.


    1. ris58h
      12.09.2022 14:53
      +2

      Это другое и похоже скорее на sealed classes в Java.


  1. onets
    12.09.2022 13:04

    3 и 4 пункт мне напомнил дефолтные реализации методов в интерфейсе в c# https://docs.microsoft.com/ru-ru/dotnet/csharp/language-reference/proposals/csharp-8.0/default-interface-methods


    1. Stefanio Автор
      12.09.2022 13:53

      Эта история уже из немного другой оперы


  1. SadOcean
    12.09.2022 13:28
    +13

    1 пункт по делу - конечно enum как константы быстрые и простые по поведению, но данных в них не хватает.
    Возможно это стоит решать не докруткой enum, а заведением честного типа-суммы специально для этих целей.
    2 пункт имеет не очень большую практическую ценность, возможно явное указание даже лучше.
    Но это на любителя.
    А вот 3 и 4 пункты наоборот видятся слабостью Java
    В c# очень рано появился хороший механизм делегатов (которые фактически и являются стандартизированным интерфейсом одного метода) и лямбды, по сути полностью исключив "интерфейс с одним методом" и сахар "реализации на месте".
    Java пришлось догонять.

    Чего еще можно добавить - неизменяемых переменных, аналога val в kotlin
    В Java в замыкание можно положить только final переменную, что значительно улучшает понимание замыканий.


    1. Stefanio Автор
      12.09.2022 13:52
      +1

      Спасибо за конструктивный комментарий!


    1. doctorw
      12.09.2022 15:27

      По поводу enum. Можно навесить на значения атрибуты с дополнительными значениями и добавить extension-метод, который их извлекает.


      1. Stefanio Автор
        12.09.2022 15:33
        +1

        Собственно, данный вариант описан в статье


    1. Kanut
      12.09.2022 16:17

      Возможно это стоит решать не докруткой enum, а заведением честного типа-суммы специально для этих целей.

      По идее есть же Case Guards:


      public readonly struct Point
      {
          public Point(int x, int y) => (X, Y) = (x, y);
      
          public int X { get; }
          public int Y { get; }
      }
      
      static Point Transform(Point point) => point switch
      {
          { X: 0, Y: 0 }                    => new Point(0, 0),
          { X: var x, Y: var y } when x < y => new Point(x + y, y),
          { X: var x, Y: var y } when x > y => new Point(x - y, y),
          { X: var x, Y: var y }            => new Point(2 * x, 2 * y),
      };

      То есть берёшь любой класс с произвольными полями и лепишь по нему нужные switch'и. Или я чего-то не понимаю?


      1. ris58h
        12.09.2022 17:44
        +1

        У вас не тип-сумма в примере, хоть и pattern matching присутствует. Посмотрите примеры в Википедии https://en.m.wikipedia.org/wiki/Tagged_union

        В Java enum - это тоже не тип сумма. Для этого там есть sealed classes. У enum в Java просто можно хранить связанные с его значениями данные, что иногда бывает удобно. Связанные со значениями enum, а не с типом, заметьте. Грубо говоря, enum в Java - это final-класс, который нельзя расширить, и несколько его именованных синглтонов. Почти как в примере реализации из статьи.


        1. slonopotamus
          15.09.2022 08:32
          +1

          Грубо говоря, enum в Java - это final-класс, который нельзя расширить, и несколько его именованных синглтонов.

          А вот и нет. Одна из фишек енумов из Java в том что можно объявить метод в енуме, но потом написать разные оверрайды в каждом из элементов.


  1. alaudo
    12.09.2022 15:34
    +1

    Отличная статья!
    По поводу enum, можно использовать Extension Method чтобы сделать что-то подобное. Вот набросал простой пример:

    void Main()
    {
    	var today = Week.Monday;
    	today.ShouldIWorkToday().Dump();
    }
    
    public enum Week 
    {
    	Monday,
    	Tuesday,
    	Wednesday,
    	Thurday,
    	Friday,
    	Saturday,
    	Sunday
    }
    
    public static class WeekHelper 
    {
    	public static bool ShouldIWorkToday(this Week day) => day != Week.Saturday && day != Week.Sunday;
    }


    1. Stefanio Автор
      12.09.2022 15:37

      Спасибо! О расширениях было написано в статье


      1. alaudo
        12.09.2022 15:38
        +2

        Точно, что-то я пролетел сразу!


    1. hbn3
      12.09.2022 23:58
      +2

      В логике ошибке, правильнее будет:

      public static bool ShouldIWorkToday(this Week day) => false;


      1. Bronx
        13.09.2022 07:23

        В С# не хватает типа never, поэтому TS мне стал милее.


        1. a-tk
          13.09.2022 08:18

          Зато в C# можно сделать тип "ни да, ни нет".


  1. DistortNeo
    12.09.2022 18:13
    +2

    Вот уж чего не хватает C#, так это реализации IEquatable<> для enum.
    Вместо этого приходится использовать костыль в виде EqualityComparer<>.Default.Equals (который, кстати, инлайнится, что не так уж и плохо).


    Насчёт пункта 4: скоро в C# завезут file-local types:
    https://github.com/dotnet/csharplang/issues/6011


  1. md_backend_binance
    12.09.2022 21:56

    Мне не хватает

    1) наследование enum

    2) деконструкторы анонимных обобщенных ограничений очень крутая фича для выдергивания на ходу нужных полей без создания структур

    3) взаимодействие микросервисов по REST с помощью типизированных интерфейсов как wcf но лайтовее в миллиард раз

    4) использование enum в обобщённых ограничений

    4й пункт я как вы знаете протолкнул. 2й пункт признаюсь очень трудно протолкнуть. 3й пункт будет через несколько версий , над этом фичей работаем вместе с командой и MVP MSFT Suzuki, Yoshizaki, Kawai (это всё азиатские имена , не фирмы ^_^)

    Обещаю что 3й пункт будет базой для создания взаимодействия микросервисов .net


    1. Stefanio Автор
      13.09.2022 00:06

      по 3 пункту гляньте Refit https://github.com/reactiveui/refit


    1. lair
      13.09.2022 00:15
      +4

      взаимодействие микросервисов по REST с помощью типизированных интерфейсов как wcf но лайтовее в миллиард раз

      А с какой радости это часть языка?


      4й пункт я как вы знаете протолкнул.

      Неа, не знаем. Где про это почитать?


      1. md_backend_binance
        13.09.2022 11:07

        По enum добавили в 7.3 Можете тут посмотреть https://dotnetfalcon.com/whats-new-in-c-7-3/ пункт "New generic type constraints"

        По пункту 3: Часть из за того что уже есть всё что нужно для этого а именно serviceHost и httpclient и interfaces

        @Stefanioнет, мы работает и взяли за основу магикюнион, который показывает максимальную производительность и самый низкий лэтенси из всех возможных подобных решений.


        1. lair
          13.09.2022 14:15
          +2

          По enum добавили в 7.3 Можете тут посмотреть https://dotnetfalcon.com/whats-new-in-c-7-3/ пункт "New generic type constraints"

          По этой ссылке нет никакого упоминания, кто это "протолкнул". Где можно про этот процесс прочитать?


          По пункту 3: Часть из за того что уже есть всё что нужно для этого а именно serviceHost и httpclient и interfaces

          HttpClient — точно не часть языка. Как вы — и, главное, зачем — планируете делать в языке что-то настолько высокоуровневое и специфическое?


    1. dmitry_dvm
      14.09.2022 13:21
      +1

      1. Grpc же


  1. xXxVano
    12.09.2022 22:50

    Честно говоря что что, а 2-й пункт никогда не вызывал вопросов. Потому что в отличие от перегрузки виртуального метода, интерфейс можно реализовать явно. Да, это +1 строчка, но имхо это несущественная мелочь.


    class Foo : ICloneable
    {
        object ICloneable.Clone() => Clone();
        public Foo Clone()
        {
            throw new NotImplementedException();
        }
    }


  1. TimeCoder
    13.09.2022 03:12

    Описывать enum, и здесь же - задавать некие значения - имхо, не в духе ООП языков. Даже взять ваш пример, некий парсер: в одном файле будет enum, как описание типа, в другом - код самого парсера, где тип используется. Какие символы надо трактовать как некий enum item - это задача и ответственность парсера. Читая его код придётся прыгать внутрь enum чтобы увидеть, какие именно символы соответствуют каждому пункту. А если правило усложнится, его описание может вообще перестать сводиться к строковому литералу. В общем случае, пункты enum могут в разных частях кода иметь разное соответствие каким-то ситуациям, значениям. Это зона ответственности кода, использующего тип. А вы в сам тип пытаетесь частично перенести логику кода, этот тип использующего. В общем случае, не на синтетически примерах, даёт ли это больше плюсов, чем минусов?


    1. a-tk
      13.09.2022 08:22

      Можете не задавать никакие номера и пользоваться только символьными именами. Так сказать, используйте перечисления канонично.


  1. kemsky
    13.09.2022 21:45
    +1

    Да, какие-то вещи непривычны после джавы, но если говорить только про язык, то скучать нет причин - джава дубовая. Их можно понять, сущестующие кодовые базы огромные. Джава это история компромисов, отсюда и эти functional интерфейсы и стирание типов и много чего еще.

    После перехода с джавы мне больше все нехватало и нехватает возможности нормально дебажить сторонний код без сорцов, декомпилировать, и в джаве было куда проще запатчить стороннюю либу.


  1. slonopotamus
    15.09.2022 08:39

    А мне в C# очень не хватает 10-летней поддержки LTS-релизов.


    1. a-tk
      15.09.2022 08:57

      А в чём трудность? Написанное 10 лет назад не работает?


      1. slonopotamus
        15.09.2022 09:31

        Написанное 10 лет назад не работает?

        Нет, не работает. Вы не можете запустить приложение сбилженное об 3.1 дотнет имея на руках только шестой рантайм. А 3.1 рантайм через три месяца станет eol.

        Кстати, обратной совместимости между рантаймами мне тоже не хватает.